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 [
|
||||
|
||||
@@ -201,6 +201,16 @@ class AiVideoAnalyzerService
|
||||
// Perform Gemini AI or Curriculum-grounded Analysis
|
||||
$analysisResult = self::generateAnalysis($lessonTitle, $duration, $curriculum);
|
||||
|
||||
if (($analysisResult['status'] ?? '') === 'needs_evidence_review') {
|
||||
return [
|
||||
'status' => 'needs_evidence_review',
|
||||
'lesson_id' => $lessonId,
|
||||
'message' => 'لا يمكن نشر فصول أو أسئلة للفيديو من دون تفريغ زمني ومراجعة تربوية مرتبطة بالدرس.',
|
||||
'timeline_chapters' => [],
|
||||
'checkpoints_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 1. Save Timeline Chapters to lessons table
|
||||
if (!empty($analysisResult['timeline_chapters'])) {
|
||||
Database::query(
|
||||
@@ -407,6 +417,15 @@ class AiVideoAnalyzerService
|
||||
*/
|
||||
private static function generateAnalysis(string $lessonTitle, int $duration, array $curriculum): array
|
||||
{
|
||||
// This legacy signature receives neither a timestamped transcript nor
|
||||
// the approved Markdown version. It must never create publishable
|
||||
// video claims, even when an AI key happens to be configured.
|
||||
return [
|
||||
'timeline_chapters' => [],
|
||||
'socratic_checkpoints' => [],
|
||||
'status' => 'needs_evidence_review',
|
||||
];
|
||||
|
||||
$geminiKey = self::nextGeminiApiKey();
|
||||
|
||||
if (!empty($geminiKey)) {
|
||||
@@ -482,8 +501,14 @@ class AiVideoAnalyzerService
|
||||
}
|
||||
}
|
||||
|
||||
// High-Precision Curriculum Grounded Engine (Offline / Safe Fallback)
|
||||
return self::buildGroundedCurriculumAnalysis($lessonTitle, $duration, $curriculum);
|
||||
// A title and curriculum outline do not prove what appears at a given
|
||||
// moment in the video. Do not publish chapters or questions without
|
||||
// transcript evidence and a successful review workflow.
|
||||
return [
|
||||
'timeline_chapters' => [],
|
||||
'socratic_checkpoints' => [],
|
||||
'status' => 'needs_evidence_review',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,14 +27,14 @@ class CliqPaymentService
|
||||
public const DEFAULT_BANK_NAME = 'بنك الاتحاد / البنك العربي (الأردن)';
|
||||
|
||||
/**
|
||||
* إنشاء الجداول المخصصة لمدفوعات كليك وطابور السحب تلقائياً إن لم تكن موجودة
|
||||
* @deprecated Schema is installed through migrations only. This no-op
|
||||
* prevents legacy callers from running DDL during an HTTP request.
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
static $ran = false;
|
||||
if ($ran) return;
|
||||
$ran = true;
|
||||
|
||||
/* Runtime DDL removed; apply 20260909_payment_safety_hold.sql instead. */
|
||||
return;
|
||||
/*
|
||||
try {
|
||||
// جدول مدفوعات كليك الواردة من الطلاب
|
||||
Database::query(
|
||||
@@ -79,6 +79,16 @@ class CliqPaymentService
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[CliqPaymentService] ensureSchema warning: ' . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
private static function unavailable(string $operation): array
|
||||
{
|
||||
return [
|
||||
'status' => 'unavailable',
|
||||
'operation' => $operation,
|
||||
'message' => 'هذه العملية المالية غير متاحة حتى يكتمل ربط مزود الدفع والتسوية الموثقة. لم يتم إنشاء حركة مالية أو تفعيل وصول أو سحب رصيد.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +96,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function initiatePayment(int $studentId, int $courseId): array
|
||||
{
|
||||
return self::unavailable('initiate_payment');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$course = Database::selectOne("SELECT id, title, price_jod, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
|
||||
@@ -130,6 +142,8 @@ class CliqPaymentService
|
||||
string $receiptBase64,
|
||||
?string $manualTxId = null
|
||||
): array {
|
||||
return self::unavailable('submit_receipt');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$payment = Database::selectOne(
|
||||
@@ -225,6 +239,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function requestTeacherPayout(int $teacherId, float $amountJod, string $teacherCliqAlias): array
|
||||
{
|
||||
return self::unavailable('request_payout');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
if ($amountJod < 20.00) {
|
||||
@@ -259,6 +275,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function processPayoutQueue(): array
|
||||
{
|
||||
return self::unavailable('process_payout_queue');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$queuedItems = Database::select(
|
||||
@@ -298,6 +316,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function getTeacherPayouts(int $teacherId): array
|
||||
{
|
||||
return [];
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$payouts = Database::select(
|
||||
|
||||
@@ -28,56 +28,11 @@ class CurriculumService
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime migration: add is_system_curriculum (and related columns) to `courses`
|
||||
* if they are missing. Safe to call multiple times — uses IF NOT EXISTS / IGNORE.
|
||||
* Compatibility hook. Course schema is managed by migrations, never by requests.
|
||||
*/
|
||||
public static function ensureSystemCurriculumColumn(): void
|
||||
{
|
||||
static $ran = false;
|
||||
if ($ran) return;
|
||||
$ran = true;
|
||||
|
||||
try {
|
||||
// Check whether the column exists to avoid noisy ALTER errors on every request
|
||||
$row = Database::selectOne(
|
||||
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'courses'
|
||||
AND COLUMN_NAME = 'is_system_curriculum'
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
if (empty($row['cnt'])) {
|
||||
Database::query(
|
||||
"ALTER TABLE `courses`
|
||||
ADD COLUMN `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'الدورة الرئيسية للمنهاج الوزاري'
|
||||
AFTER `is_school_exclusive`"
|
||||
);
|
||||
}
|
||||
|
||||
// Also ensure grade_level + stream columns exist (for student filtering)
|
||||
$gradeRow = Database::selectOne(
|
||||
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'courses'
|
||||
AND COLUMN_NAME = 'grade_level'
|
||||
LIMIT 1"
|
||||
);
|
||||
if (empty($gradeRow['cnt'])) {
|
||||
Database::query(
|
||||
"ALTER TABLE `courses`
|
||||
ADD COLUMN `grade_level` VARCHAR(50) DEFAULT NULL
|
||||
COMMENT 'المرحلة الدراسية مثل: tawjihi_2008'
|
||||
AFTER `is_system_curriculum`,
|
||||
ADD COLUMN `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL
|
||||
COMMENT 'الفرع الدراسي'
|
||||
AFTER `grade_level`"
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[CurriculumService] ensureSystemCurriculumColumn error: ' . $e->getMessage());
|
||||
}
|
||||
// Intentionally empty. Run backend/scripts/run_migrations.php before serving.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** Reads only the reviewed package attached to the exact published curriculum lesson. */
|
||||
final class LearningPackageService {
|
||||
public static function publishedEnglish(string $lessonUuid,int $studentId,?string $nationalId):array {
|
||||
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',$lessonUuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية الدرس غير صالحة.'];
|
||||
$lesson=Database::selectOne("SELECT cl.id,cl.uuid,cl.grade_key,cl.title FROM curriculum_lessons cl WHERE cl.uuid=? AND cl.source_status='approved' LIMIT 1",[$lessonUuid]);
|
||||
if(!$lesson)return ['http_status'=>404,'status'=>'error','message'=>'الدرس غير منشور.'];
|
||||
$access=StudentAccessControlService::validateLessonAccess($studentId,$nationalId,StudentAccessControlService::normalizeGrade((string)$lesson['grade_key']),null,null,false);
|
||||
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالوصول إلى الدرس.'];
|
||||
$package=Database::selectOne("SELECT lp.id,lp.structure_json,pb.bundle_version FROM lesson_learning_packages lp JOIN publication_bundles pb ON pb.id=lp.bundle_id AND pb.status='published' WHERE lp.curriculum_lesson_id=? AND lp.package_type='english' AND lp.status='published' LIMIT 1",[$lesson['id']]);
|
||||
if(!$package)return ['http_status'=>404,'status'=>'not_ready','message'=>'حزمة الإنجليزية المعتمدة لهذا الدرس لم تُنشر بعد.'];
|
||||
$entries=Database::select("SELECT e.lemma,e.part_of_speech,e.meaning_ar,e.ipa_verified,a.uuid AS audio_asset_id FROM english_lexical_entries e LEFT JOIN content_assets a ON a.id=e.audio_asset_id AND a.review_status='approved' WHERE e.lesson_learning_package_id=? AND e.review_status='approved' ORDER BY e.id",[$package['id']]);
|
||||
return ['http_status'=>200,'status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'bundle_version'=>$package['bundle_version'],'structure'=>json_decode((string)$package['structure_json'],true)??[],'lexical_entries'=>$entries]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
/** Resolves only approved assets belonging to a currently published bundle. */
|
||||
final class PublishedContentService
|
||||
{
|
||||
private const STORAGE_ROOT = __DIR__ . '/../../storage/curriculum';
|
||||
|
||||
public static function findPublishedAsset(string $assetUuid): ?array
|
||||
{
|
||||
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', $assetUuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Database::selectOne(
|
||||
"SELECT a.uuid, a.asset_type, a.storage_driver, a.storage_key, a.mime_type,
|
||||
a.byte_size, a.sha256, cl.uuid AS curriculum_lesson_uuid,
|
||||
cl.grade_key, cl.subject_key, cl.semester_key, cl.unit_key,
|
||||
cl.lesson_key, pb.bundle_version
|
||||
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
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
WHERE a.uuid = ?
|
||||
AND a.review_status = 'approved'
|
||||
AND pb.status = 'published'
|
||||
AND cl.source_status = 'approved'
|
||||
ORDER BY pb.published_at DESC, pb.id DESC
|
||||
LIMIT 1",
|
||||
[$assetUuid]
|
||||
);
|
||||
}
|
||||
|
||||
public static function readLocalAsset(array $asset): ?string
|
||||
{
|
||||
if (($asset['storage_driver'] ?? '') !== 'local') {
|
||||
return null;
|
||||
}
|
||||
$root = realpath(self::STORAGE_ROOT);
|
||||
$path = $root ? realpath($root . '/' . ltrim((string)$asset['storage_key'], '/')) : false;
|
||||
$prefix = $root ? rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : '';
|
||||
if (!$path || !$prefix || !str_starts_with($path, $prefix) || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ class StudentAccessControlService
|
||||
?string $nationalId,
|
||||
string $targetGrade,
|
||||
?int $courseId = null,
|
||||
?int $lessonId = null
|
||||
?int $lessonId = null,
|
||||
bool $allowSideEffects = true
|
||||
): array {
|
||||
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
||||
$student = null;
|
||||
@@ -90,7 +91,7 @@ class StudentAccessControlService
|
||||
|
||||
// Auto-heal obsolete database schema default:
|
||||
// If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10
|
||||
if (($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === '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';
|
||||
@@ -101,7 +102,7 @@ class StudentAccessControlService
|
||||
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
|
||||
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
|
||||
if ($activeStudentGrade !== $normalizedTargetGrade) {
|
||||
if (!$isCohortLocked && !empty($student['id'])) {
|
||||
if (!$isCohortLocked && !empty($student['id']) && $allowSideEffects) {
|
||||
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
|
||||
$activeStudentGrade = $normalizedTargetGrade;
|
||||
} else {
|
||||
@@ -126,7 +127,7 @@ class StudentAccessControlService
|
||||
|
||||
if ($isMilitaryCulture || $isPrivateSponsored) {
|
||||
// الطالب مشمول مجاناً 100% (صفر دينار)
|
||||
if ($courseId && !empty($student['id'])) {
|
||||
if ($allowSideEffects && $courseId && !empty($student['id'])) {
|
||||
self::ensureSchoolIncludedPass((int)$student['id'], $courseId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** 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)];
|
||||
}
|
||||
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 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'=>'الحجز غير موجود أو تمت تسويته.'];
|
||||
}
|
||||
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 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;}
|
||||
}
|
||||
@@ -15,8 +15,11 @@ class TeacherRatingService
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
// Schema is installed by migrations. Runtime DDL previously included a
|
||||
// DROP TABLE path and must never run during a student request.
|
||||
return;
|
||||
/*
|
||||
if (self::$schemaChecked) return;
|
||||
|
||||
try {
|
||||
// 1. Ensure teacher_reviews table exists with all columns
|
||||
Database::query("
|
||||
@@ -75,6 +78,7 @@ class TeacherRatingService
|
||||
} catch (\Throwable $e) {
|
||||
error_log("TeacherRatingService schema note: " . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,8 +214,8 @@ class TeacherRatingService
|
||||
);
|
||||
|
||||
$totalReviews = count($reviews);
|
||||
$weightedRating = 5.00;
|
||||
$rawRating = 5.00;
|
||||
$weightedRating = 0.00;
|
||||
$rawRating = 0.00;
|
||||
|
||||
if ($totalReviews > 0) {
|
||||
$sumWeighted = 0;
|
||||
@@ -226,7 +230,7 @@ class TeacherRatingService
|
||||
$sumRaw += $val;
|
||||
}
|
||||
|
||||
$weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 5.00;
|
||||
$weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 0.00;
|
||||
$rawRating = round($sumRaw / $totalReviews, 2);
|
||||
}
|
||||
|
||||
@@ -237,7 +241,9 @@ class TeacherRatingService
|
||||
$masteryScore = self::calculateMasteryImpact($teacherId);
|
||||
|
||||
// 4. AI Socratic Engagement Index
|
||||
$aiEngagementScore = 96.00;
|
||||
// There is no verified AI engagement measurement yet. Keep the metric
|
||||
// neutral until the reviewed video/watch pipeline supplies one.
|
||||
$aiEngagementScore = 0.00;
|
||||
|
||||
// 5. Composite Merit Calculation (The Fair Formula: 25% SLA + 25% AI + 25% Mastery + 25% Student Review)
|
||||
$reviewComponent = $weightedRating * 20.0;
|
||||
@@ -266,7 +272,11 @@ class TeacherRatingService
|
||||
}
|
||||
|
||||
// Count enrolled students
|
||||
$studentsCount = (int)(Database::selectOne("SELECT COUNT(*) as cnt FROM students")['cnt'] ?? 0);
|
||||
$studentsCount = (int)(Database::selectOne(
|
||||
"SELECT COUNT(DISTINCT lp.student_id) AS cnt FROM lesson_progress lp
|
||||
JOIN lessons l ON l.id=lp.lesson_id JOIN courses c ON c.id=l.course_id
|
||||
WHERE c.teacher_id=?", [$teacherId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
// Upsert into teacher_performance_metrics
|
||||
Database::query(
|
||||
@@ -295,7 +305,7 @@ class TeacherRatingService
|
||||
$slaData['avg_response_minutes'],
|
||||
$slaData['response_rate_pct'],
|
||||
$slaData['active_queue_count'],
|
||||
max(1, $studentsCount),
|
||||
$studentsCount,
|
||||
$totalReviews,
|
||||
$rawRating,
|
||||
$weightedRating,
|
||||
@@ -364,20 +374,19 @@ class TeacherRatingService
|
||||
$totalReplies = (int)(Database::selectOne("SELECT COUNT(DISTINCT receiver_id) as cnt FROM chat_messages WHERE sender_id = ?", [$teacherId])['cnt'] ?? 0);
|
||||
|
||||
// Calculate Base Response Rate
|
||||
$responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 99.0;
|
||||
if ($responseRate < 80.0) $responseRate = 95.0;
|
||||
$responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 0.0;
|
||||
|
||||
$baseResponseMinutes = 3;
|
||||
$baseResponseMinutes = 0;
|
||||
|
||||
if ($isActivelySolving && $queueDepth > 0) {
|
||||
$effectiveSlaScore = 99.0;
|
||||
$effectiveSlaScore = 0.0;
|
||||
$effectiveMinutes = $baseResponseMinutes + min(2, (int)($queueDepth * 0.5));
|
||||
} elseif ($queueDepth > 0) {
|
||||
$effectiveMinutes = $baseResponseMinutes + min(5, $queueDepth);
|
||||
$effectiveSlaScore = max(90.0, 98.0 - ($queueDepth * 0.8));
|
||||
$effectiveSlaScore = 0.0;
|
||||
} else {
|
||||
$effectiveMinutes = $baseResponseMinutes;
|
||||
$effectiveSlaScore = 99.5;
|
||||
$effectiveSlaScore = 0.0;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -393,11 +402,11 @@ class TeacherRatingService
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_response_minutes' => 3,
|
||||
'response_rate_pct' => 99.0,
|
||||
'avg_response_minutes' => 0,
|
||||
'response_rate_pct' => 0.0,
|
||||
'active_queue_count' => 0,
|
||||
'is_active_solving' => true,
|
||||
'sla_score' => 98.0
|
||||
'is_active_solving' => false,
|
||||
'sla_score' => 0.0
|
||||
];
|
||||
}
|
||||
|
||||
@@ -436,7 +445,7 @@ class TeacherRatingService
|
||||
FROM teachers t
|
||||
LEFT JOIN teacher_performance_metrics m ON t.id = m.teacher_id
|
||||
WHERE t.is_marketplace_public = 1
|
||||
ORDER BY " . ($sortBy === 'fastest' ? "COALESCE(m.avg_response_minutes, 10) ASC" : "COALESCE(m.composite_merit_score, 90.00) DESC")
|
||||
ORDER BY " . ($sortBy === 'fastest' ? "CASE WHEN m.avg_response_minutes IS NULL OR m.avg_response_minutes = 0 THEN 1 ELSE 0 END, m.avg_response_minutes ASC" : "COALESCE(m.total_reviews_count, 0) DESC, COALESCE(m.weighted_student_rating, 0) DESC")
|
||||
);
|
||||
|
||||
foreach ($teachers as &$t) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
final class TeacherSubmissionService {
|
||||
public static function preflight(int $teacherId, string $lessonUuid, string $key, bool $replacement, ?string $submissionUuid = null): array {
|
||||
if (!self::uuidValid($lessonUuid) || !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) return self::result(400, 'error', 'بيانات هوية الدرس أو مفتاح الإعادة غير صالحة.');
|
||||
$operation = $replacement ? 'replacement_preflight' : 'submission_preflight';
|
||||
$hash = hash('sha256', json_encode([$lessonUuid, $replacement, $submissionUuid]));
|
||||
$pdo = Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$previous = Database::selectOne('SELECT request_sha256,response_json,http_status FROM submission_idempotency_keys WHERE teacher_id=? AND operation=? AND idempotency_key=? FOR UPDATE', [$teacherId,$operation,$key]);
|
||||
if ($previous) {
|
||||
if (!hash_equals((string)$previous['request_sha256'], $hash)) { $pdo->rollBack(); return self::result(409,'idempotency_conflict','استُخدم مفتاح الإعادة مع طلب مختلف.'); }
|
||||
$pdo->commit(); return array_merge(json_decode((string)$previous['response_json'], true) ?: [], ['http_status'=>(int)$previous['http_status'],'replayed'=>true]);
|
||||
}
|
||||
$lesson = Database::selectOne("SELECT id,uuid,title FROM curriculum_lessons WHERE uuid=? AND source_status='approved' LIMIT 1 FOR UPDATE", [$lessonUuid]);
|
||||
if (!$lesson) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(404,'error','الدرس المنهجي غير معتمد أو غير موجود.'));
|
||||
$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 ($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;
|
||||
Database::insert("INSERT INTO video_versions (uuid,teacher_submission_id,version_number,status,replaces_video_version_id) VALUES (?,?,?,'draft',?)",[self::uuid(),$submission['id'],$next,$submission['current_published_video_version_id'] ?: null]);
|
||||
$version=Database::selectOne('SELECT uuid FROM video_versions WHERE teacher_submission_id=? AND version_number=?',[$submission['id'],$next]);
|
||||
return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>201,'status'=>'ready_for_upload','submission_id'=>$submission['uuid'],'video_version_id'=>$version['uuid'],'version_number'=>$next,'replacement'=>$replacement,'lesson'=>['id'=>$lesson['uuid'],'title'=>$lesson['title']]]);
|
||||
} catch (\Throwable $e) { if($pdo->inTransaction())$pdo->rollBack(); throw $e; }
|
||||
}
|
||||
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;}
|
||||
}
|
||||
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'
|
||||
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='draft'",
|
||||
[$versionUuid, $teacherId]
|
||||
) === 1;
|
||||
}
|
||||
private static function commit(\PDO $pdo,int $teacher,string $op,string $key,string $hash,array $r):array {Database::insert('INSERT INTO submission_idempotency_keys (teacher_id,operation,idempotency_key,request_sha256,response_json,http_status) VALUES (?,?,?,?,?,?)',[$teacher,$op,$key,$hash,json_encode($r,JSON_UNESCAPED_UNICODE),$r['http_status']]);$pdo->commit();return $r;}
|
||||
private static function result(int $status,string $code,string $message):array{return ['http_status'=>$status,'status'=>$code,'message'=>$message];}
|
||||
private static function uuidValid(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));}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
final class VideoReviewService
|
||||
{
|
||||
public static function recordHumanReview(string $jobUuid, int $reviewerId, string $recommendation, string $decision, array $report): array
|
||||
{
|
||||
if (!in_array($recommendation, ['reject','revise','manual_review','ready_for_human_review'], true) || !in_array($decision, ['approved','rejected'], true)) {
|
||||
return ['http_status'=>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;
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,13 @@ class VideoService
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
// Schema is installed by backend/scripts/run_migrations.php.
|
||||
// Legacy request-time DDL remains below only as historical reference.
|
||||
return;
|
||||
/*
|
||||
if (self::$schemaChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if storage_type column exists
|
||||
$cols = Database::select("SHOW COLUMNS FROM lessons LIKE 'storage_type'");
|
||||
@@ -91,6 +94,7 @@ class VideoService
|
||||
} catch (\Throwable $e) {
|
||||
error_log("VideoService schema ensure note: " . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** Server-owned watch evidence. Client positions are hints, never a balance. */
|
||||
final class WatchSessionService {
|
||||
public static function start(int $studentId, string $versionUuid, ?string $nationalId): array {
|
||||
$row=self::authorisedVersion($studentId,$versionUuid,$nationalId);
|
||||
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]; }
|
||||
$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);
|
||||
Database::insert("INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,1,'start',0,?)",[$id,json_encode(['server_contract'=>'watch-v1'])]);
|
||||
$pdo->commit(); return ['http_status'=>201,'status'=>'started','watch_session_id'=>$uuid];
|
||||
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
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]);
|
||||
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'=>'انتهت جلسة المشاهدة.']);
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
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);
|
||||
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')];
|
||||
}
|
||||
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 $r):array{$pdo->commit();return $r;}
|
||||
}
|
||||
@@ -114,15 +114,13 @@ class GuardianPortal
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span style="font-size: 12px; font-weight: 800; color: var(--accent-cyan); display: block; margin-bottom: 6px;">متابعة التحصيل الأكاديمي والجاهزية الوزارية 📊</span>
|
||||
<h1 style="font-size: 24px; font-weight: 900; color: #FFF;" id="selected_child_name">الابن: أحمد (الفرع العلمي)</h1>
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">مدرسة الثقافة العسكرية المعتمدة • الرقم الوطني: 2008XXXX12</p>
|
||||
<h1 style="font-size: 24px; font-weight: 900; color: #FFF;" id="selected_child_name">لا توجد بيانات طالب مرتبطة بعد</h1>
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">اربط حساب الطالب المصرح به لعرض بياناته الفعلية.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Child Switching Pill -->
|
||||
<div class="child-selector-pill">
|
||||
<button class="child-btn active" onclick="switchChildProfile(0, 'أحمد (علمي)', '2008XXXX12', '94.2%')">أحمد (توجيهي)</button>
|
||||
<button class="child-btn" onclick="switchChildProfile(1, 'سارة (توجيهي 2008)', '2008XXXX34', '96.8%')">سارة</button>
|
||||
<button class="child-btn" onclick="switchChildProfile(2, 'عمر (الصف العاشر)', '2010XXXX56', '89.5%')">عمر</button>
|
||||
<span style="font-size:12px;color:var(--text-muted);">لا يوجد طلاب مرتبطون بالحساب</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,8 +128,8 @@ class GuardianPortal
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<span style="font-size: 12px; color: var(--text-muted); display: block; margin-bottom: 6px;">مؤشر الجاهزية للوزاري (Socratic Readiness)</span>
|
||||
<div class="metric-val val-cyan" id="metric_readiness">94.2%</div>
|
||||
<span style="font-size: 12px; color: var(--accent-green); display: block; margin-top: 8px;">✓ مستوى متقدم جداً ومؤهل لعلامة كاملة</span>
|
||||
<div class="metric-val val-cyan" id="metric_readiness">غير متاح</div>
|
||||
<span style="font-size: 12px; color: var(--text-muted); display: block; margin-top: 8px;">تظهر القراءة بعد وصول بيانات الطالب الفعلية.</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -727,8 +727,8 @@ class StudentPortal
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 14px 0;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">مؤشر الجدارة الكلي</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);"><?= number_format((float)($t['composite_merit_score'] ?? 96.5), 1) ?>%</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">★ <?= number_format((float)($t['star_equivalent'] ?? 4.9), 1) ?></span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);"><?= isset($t['composite_merit_score']) ? number_format((float)$t['composite_merit_score'], 1) . '%' : 'غير متاح' ?></span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);"><?= isset($t['star_equivalent']) ? '★ ' . number_format((float)$t['star_equivalent'], 1) : 'لا توجد تقييمات موثقة' ?></span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">سرعة الرد (Workerman)</span>
|
||||
@@ -2501,8 +2501,8 @@ class StudentPortal
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 14px 0;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">مؤشر الجدارة الكلي</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);">${parseFloat(t.composite_merit_score || 96.5).toFixed(1)}%</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">★ ${parseFloat(t.star_equivalent || 4.9).toFixed(1)}</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);">${t.composite_merit_score == null ? 'غير متاح' : `${parseFloat(t.composite_merit_score).toFixed(1)}%`}</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">${t.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(t.star_equivalent).toFixed(1)}`}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">سرعة الرد (Workerman)</span>
|
||||
|
||||
@@ -418,15 +418,15 @@ class TeacherPortal
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">الكورسات المنشورة</span>
|
||||
<span class="stat-val val-cyan" id="stat_courses_count"><?= max(1, $coursesCount) ?></span>
|
||||
<span class="stat-val val-cyan" id="stat_courses_count"><?= (int)$coursesCount ?></span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">الطلاب المتفاعلون</span>
|
||||
<span class="stat-val val-purple" id="stat_students_count"><?= max(1, $studentsCount) ?></span>
|
||||
<span class="stat-val val-purple" id="stat_students_count"><?= (int)$studentsCount ?></span>
|
||||
</div>
|
||||
<div class="stat-card gold-glow">
|
||||
<span class="stat-label">متوسط إتقان الطلاب</span>
|
||||
<span class="stat-val val-gold" id="stat_mastery_avg">94.6%</span>
|
||||
<span class="stat-val val-gold" id="stat_mastery_avg">غير متاح</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,25 +494,25 @@ class TeacherPortal
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">مؤشر الجدارة الكلي المركب</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-gold);" id="rep_merit_score">96.8%</span>
|
||||
<span style="font-size: 11px; color: var(--accent-cyan); display: block; margin-top: 4px;" id="rep_tier_badge">معلم نخبوي معتمد 💎</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-gold);" id="rep_merit_score">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--accent-cyan); display: block; margin-top: 4px;" id="rep_tier_badge">قيد بناء سجل التقييم</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">سرعة الاستجابة (Workerman SLA)</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #34D399;" id="rep_response_speed">3 دقائق ⚡</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_response_rate">نسبة التجاوب: 99.2%</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #34D399;" id="rep_response_speed">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_response_rate">نسبة التجاوب: غير متاحة</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">تقييم الطلاب المعاير والموزون</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #A78BFA;" id="rep_weighted_rating">★ 4.90 / 5.0</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #A78BFA;" id="rep_weighted_rating">لا توجد تقييمات موثقة</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_reviews_count">بناءً على التقييمات الموثقة</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">معدل رفع إتقان الطلاب (Mastery)</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-cyan);" id="rep_mastery_gain">+94.6%</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-cyan);" id="rep_mastery_gain">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;">في الكويزات السقراطية والامتحانات</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1517,13 +1517,13 @@ class TeacherPortal
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success' && data.data) {
|
||||
const m = data.data;
|
||||
document.getElementById('rep_merit_score').textContent = `${parseFloat(m.composite_merit_score || 96.8).toFixed(1)}%`;
|
||||
document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'معلم نخبوي معتمد 💎';
|
||||
document.getElementById('rep_response_speed').textContent = `${parseInt(m.avg_response_minutes || 3)} دقائق ⚡`;
|
||||
document.getElementById('rep_response_rate').textContent = `نسبة التجاوب: ${parseFloat(m.response_rate_percentage || 99.2).toFixed(1)}%`;
|
||||
document.getElementById('rep_weighted_rating').textContent = `★ ${parseFloat(m.star_equivalent || 4.9).toFixed(2)} / 5.0`;
|
||||
document.getElementById('rep_merit_score').textContent = m.composite_merit_score == null ? 'غير متاح' : `${parseFloat(m.composite_merit_score).toFixed(1)}%`;
|
||||
document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'قيد بناء سجل التقييم';
|
||||
document.getElementById('rep_response_speed').textContent = m.avg_response_minutes == null ? 'غير متاح' : `${parseInt(m.avg_response_minutes)} دقائق ⚡`;
|
||||
document.getElementById('rep_response_rate').textContent = m.response_rate_percentage == null ? 'نسبة التجاوب: غير متاحة' : `نسبة التجاوب: ${parseFloat(m.response_rate_percentage).toFixed(1)}%`;
|
||||
document.getElementById('rep_weighted_rating').textContent = m.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(m.star_equivalent).toFixed(2)} / 5.0`;
|
||||
document.getElementById('rep_reviews_count').textContent = `بناءً على ${m.total_reviews_count || 0} تقييم موثق`;
|
||||
document.getElementById('rep_mastery_gain').textContent = `+${parseFloat(m.mastery_impact_score || 94.6).toFixed(1)}%`;
|
||||
document.getElementById('rep_mastery_gain').textContent = m.mastery_impact_score == null ? 'غير متاح' : `+${parseFloat(m.mastery_impact_score).toFixed(1)}%`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load rep data notice:', e);
|
||||
|
||||
Reference in New Issue
Block a user