Harden published curriculum and student flows
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\RedisClient;
|
||||
|
||||
/** Short-lived, asset-scoped browser grant. Never place a session JWT in a URL. */
|
||||
final class AssetDownloadTicketService
|
||||
{
|
||||
private const TTL_SECONDS = 120;
|
||||
|
||||
public static function issue(int $studentId, string $assetUuid): array
|
||||
{
|
||||
if ($studentId <= 0 || $assetUuid === '') throw new \InvalidArgumentException('Invalid download scope');
|
||||
$ticket = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
$key = 'asset_download:' . hash('sha256', $ticket);
|
||||
$saved = RedisClient::getInstance()->setex($key, self::TTL_SECONDS, json_encode([
|
||||
'student_id' => $studentId,
|
||||
'asset_uuid' => $assetUuid,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
if (!$saved) throw new \RuntimeException('Download grant unavailable');
|
||||
return ['ticket' => $ticket, 'expires_in_seconds' => self::TTL_SECONDS];
|
||||
}
|
||||
|
||||
public static function resolve(string $ticket, string $assetUuid): ?int
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $ticket)) return null;
|
||||
$payload = RedisClient::getInstance()->get('asset_download:' . hash('sha256', $ticket));
|
||||
if (!is_string($payload)) return null;
|
||||
$grant = json_decode($payload, true);
|
||||
if (!is_array($grant) || !hash_equals((string)($grant['asset_uuid'] ?? ''), $assetUuid)) return null;
|
||||
$studentId = (int)($grant['student_id'] ?? 0);
|
||||
return $studentId > 0 ? $studentId : null;
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,6 @@ class CurriculumService
|
||||
*/
|
||||
public static function getCurriculumTree(): array
|
||||
{
|
||||
self::ensureStorage();
|
||||
if (!file_exists(self::$manifestFile)) {
|
||||
return [];
|
||||
}
|
||||
@@ -281,13 +280,19 @@ class CurriculumService
|
||||
*/
|
||||
public static function saveLessonMarkdown(string $relativePath, string $content): bool
|
||||
{
|
||||
if (!self::safeMarkdownPath($relativePath)) return false;
|
||||
self::ensureStorage();
|
||||
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
||||
$root = realpath(self::$storagePath);
|
||||
if ($root === false) return false;
|
||||
$fullPath = $root . '/' . $relativePath;
|
||||
$dir = dirname($fullPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
if (!mkdir($dir, 0750, true) && !is_dir($dir)) return false;
|
||||
}
|
||||
return file_put_contents($fullPath, $content) !== false;
|
||||
$realDir = realpath($dir);
|
||||
if ($realDir === false || !str_starts_with($realDir, $root . DIRECTORY_SEPARATOR)) return false;
|
||||
if (is_link($fullPath)) return false;
|
||||
return file_put_contents($fullPath, $content, LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,12 +300,21 @@ class CurriculumService
|
||||
*/
|
||||
public static function getLessonMarkdown(string $relativePath): string
|
||||
{
|
||||
self::ensureStorage();
|
||||
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
||||
if (file_exists($fullPath)) {
|
||||
return file_get_contents($fullPath);
|
||||
$root = realpath(self::$storagePath);
|
||||
if ($root === false || !self::safeMarkdownPath($relativePath)) return '';
|
||||
$fullPath = realpath($root . '/' . $relativePath);
|
||||
if ($fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) return '';
|
||||
return file_get_contents($fullPath) ?: '';
|
||||
}
|
||||
|
||||
public static function safeMarkdownPath(string $path): bool
|
||||
{
|
||||
if ($path === '' || str_starts_with($path, '/') || str_contains($path, '\\')
|
||||
|| !str_ends_with(strtolower($path), '.md') || preg_match('/[\x00-\x1f]/', $path)) return false;
|
||||
foreach (explode('/', $path) as $part) {
|
||||
if ($part === '' || $part === '.' || $part === '..') return false;
|
||||
}
|
||||
return "# محتوى المنهاج\nالمحتوى المعتمد للمنهاج الرسمي.";
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveLessonAiAssets(string $relativePath, array $assets): bool
|
||||
|
||||
@@ -137,7 +137,7 @@ class NabehService
|
||||
|
||||
// 1. Attempt with selected type (image card)
|
||||
$result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken);
|
||||
if ($result['success']) {
|
||||
if ($result['success'] || !empty($result['delivery_unknown'])) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -192,18 +192,18 @@ class NabehService
|
||||
if ($curlError) {
|
||||
error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError);
|
||||
|
||||
// Resilient Fallback: If gateway response timed out after dispatching (duration >= 18s),
|
||||
// Nabeh has already received and queued the WhatsApp image/text message.
|
||||
// The OTP is already stored in Redis on Saqel.
|
||||
// A timeout does not prove whether Nabeh accepted or delivered the message.
|
||||
// Do not report success or send a second OTP via the text fallback: that could
|
||||
// create a duplicate while the original gateway request is still completing.
|
||||
if ($duration >= 18.0 && str_contains(strtolower($curlError), 'timed out')) {
|
||||
error_log("⚠️ [Nabeh OTP Tolerant Dispatch] WhatsApp ({$type}) message likely queued/dispatched despite gateway latency ({$duration}s). Proceeding.");
|
||||
error_log("⚠️ [Nabeh OTP Delivery Unknown] WhatsApp ({$type}) request timed out after {$duration}s; delivery is unconfirmed.");
|
||||
return [
|
||||
'success' => true,
|
||||
'type_used' => $type,
|
||||
'duration' => "{$duration}s",
|
||||
'http_code' => 200,
|
||||
'message' => 'تم إرسال رمز التحقق بنجاح عبر بطاقة الواتساب المصورة',
|
||||
'note' => 'Dispatched under gateway latency'
|
||||
'success' => false,
|
||||
'delivery_unknown' => true,
|
||||
'type_used' => $type,
|
||||
'duration' => "{$duration}s",
|
||||
'http_code' => $httpCode,
|
||||
'error' => 'انتهت مهلة بوابة واتساب؛ لم يصل تأكيد التسليم. تحقّق من الرسائل قبل طلب رمز جديد.'
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -51,31 +51,24 @@ final class PublishedContentService
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
$root . '/' . $storageKey,
|
||||
$root . '/_incoming/' . $storageKey,
|
||||
dirname($root) . '/' . $storageKey,
|
||||
dirname($root, 2) . '/' . $storageKey,
|
||||
];
|
||||
|
||||
if (preg_match('#staged/grade_10/[^/]+/(.+)#', $storageKey, $matches)) {
|
||||
$inner = $matches[1];
|
||||
$candidates[] = $root . '/' . $inner;
|
||||
$candidates[] = $root . '/_incoming/' . $inner;
|
||||
}
|
||||
|
||||
// Published storage keys must resolve to their exact file inside the
|
||||
// curriculum root. Do not fall back to another similarly named file.
|
||||
$real = realpath($root . '/' . $storageKey);
|
||||
$prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$projectRoot = realpath(dirname(__DIR__, 2)) ?: '';
|
||||
return $real !== false && str_starts_with($real, $prefix) && is_file($real) ? $real : null;
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$real = realpath($candidate);
|
||||
if ($real && is_file($real)) {
|
||||
// Ensure candidate is strictly within the project root to prevent traversal
|
||||
if ($projectRoot !== '' && str_starts_with($real, $projectRoot)) {
|
||||
return $real;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
public static function studentMayRead(int $studentId, array $asset): bool
|
||||
{
|
||||
if ($studentId <= 0 || empty($asset['grade_key'])) return false;
|
||||
$student = Database::selectOne(
|
||||
"SELECT s.grade_level FROM students s
|
||||
JOIN auth_identities ai ON ai.id=s.identity_id AND ai.status='active'
|
||||
WHERE s.id=? LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if (!$student || empty($student['grade_level'])) return false;
|
||||
return StudentAccessControlService::normalizeGrade((string)$student['grade_level'])
|
||||
=== StudentAccessControlService::normalizeGrade((string)$asset['grade_key']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Builds a student catalogue from published, rights-cleared lesson identities. */
|
||||
final class PublishedCurriculumTreeFilter
|
||||
{
|
||||
public static function filter(array $manifest, array $publishedLessons): array
|
||||
{
|
||||
$approved = [];
|
||||
foreach ($publishedLessons as $row) {
|
||||
$key = self::key(
|
||||
(string)$row['grade_key'], (string)$row['subject_key'],
|
||||
(string)$row['semester_key'], (string)$row['unit_key'],
|
||||
(string)$row['lesson_key'], (string)$row['source_manifest_path']
|
||||
);
|
||||
// Rows are ordered newest-first by the caller. One visible version
|
||||
// per exact identity; never infer identity from title or file alone.
|
||||
$approved[$key] ??= $row;
|
||||
}
|
||||
|
||||
$visible = [];
|
||||
foreach ($manifest as $gradeKey => $grade) {
|
||||
if (!is_array($grade) || !is_array($grade['subjects'] ?? null)) continue;
|
||||
$subjects = [];
|
||||
foreach ($grade['subjects'] as $subjectKey => $subject) {
|
||||
if (!is_array($subject) || !is_array($subject['semesters'] ?? null)) continue;
|
||||
$semesters = [];
|
||||
foreach ($subject['semesters'] as $semesterKey => $semester) {
|
||||
if (!is_array($semester) || !is_array($semester['units'] ?? null)) continue;
|
||||
$units = [];
|
||||
foreach ($semester['units'] as $unitKey => $unit) {
|
||||
if (!is_array($unit) || !is_array($unit['lessons'] ?? null)) continue;
|
||||
$lessons = [];
|
||||
foreach ($unit['lessons'] as $lesson) {
|
||||
if (!is_array($lesson)) continue;
|
||||
$key = self::key(
|
||||
(string)$gradeKey, (string)$subjectKey,
|
||||
(string)$semesterKey, (string)$unitKey,
|
||||
(string)($lesson['id'] ?? ''), (string)($lesson['file'] ?? '')
|
||||
);
|
||||
$row = $approved[$key] ?? null;
|
||||
if ($row === null) continue;
|
||||
$lessons[] = [
|
||||
'id' => (string)$row['lesson_key'],
|
||||
'title' => (string)$row['title'],
|
||||
'curriculum_lesson_id' => (string)$row['uuid'],
|
||||
'curriculum_version' => (string)$row['curriculum_version'],
|
||||
'primary_lesson_asset_id' => (string)($row['primary_asset_id'] ?? ''),
|
||||
'has_video' => !empty($row['has_video']),
|
||||
];
|
||||
}
|
||||
if ($lessons === []) continue;
|
||||
$unit['lessons'] = $lessons;
|
||||
$units[$unitKey] = $unit;
|
||||
}
|
||||
if ($units === []) continue;
|
||||
$semester['units'] = $units;
|
||||
$semesters[$semesterKey] = $semester;
|
||||
}
|
||||
if ($semesters === []) continue;
|
||||
$subject['semesters'] = $semesters;
|
||||
$subject['resources'] = ['textbooks' => ['items' => []], 'worksheets' => ['items' => []]];
|
||||
$subjects[$subjectKey] = $subject;
|
||||
}
|
||||
if ($subjects === []) continue;
|
||||
$grade['subjects'] = $subjects;
|
||||
$visible[$gradeKey] = $grade;
|
||||
}
|
||||
return $visible;
|
||||
}
|
||||
|
||||
private static function key(string ...$parts): string
|
||||
{
|
||||
return json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Grades one immutable server snapshot; no client-supplied answer key. */
|
||||
final class RemediationAttemptGrader
|
||||
{
|
||||
public static function grade(array $questionIds, array $answerKey, array $answers): int
|
||||
{
|
||||
if (count($questionIds) !== 3 || count(array_unique($questionIds)) !== 3 ||
|
||||
count($answerKey) !== 3 || array_keys($answers) !== [0, 1, 2]) {
|
||||
throw new \InvalidArgumentException('Invalid quiz shape');
|
||||
}
|
||||
$correct = 0;
|
||||
foreach ($questionIds as $index => $questionId) {
|
||||
$key = $answerKey[$index] ?? null;
|
||||
$selected = $answers[$index];
|
||||
if (!is_array($key) || !is_int($selected) || $selected <= 0 ||
|
||||
(int)($key['question_id'] ?? 0) !== (int)$questionId ||
|
||||
!is_array($key['option_ids'] ?? null) ||
|
||||
!in_array($selected, $key['option_ids'], true) ||
|
||||
!in_array((int)($key['correct_option_id'] ?? 0), $key['option_ids'], true)) {
|
||||
throw new \InvalidArgumentException('Answer does not belong to the attempt');
|
||||
}
|
||||
if ($selected === (int)$key['correct_option_id']) $correct++;
|
||||
}
|
||||
return $correct;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class StudentAccessControlService
|
||||
string $targetGrade,
|
||||
?int $courseId = null,
|
||||
?int $lessonId = null,
|
||||
bool $allowSideEffects = true
|
||||
bool $allowSideEffects = false
|
||||
): array {
|
||||
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
||||
$student = null;
|
||||
@@ -64,19 +64,9 @@ class StudentAccessControlService
|
||||
);
|
||||
}
|
||||
|
||||
// إذا لم يكن مسجلاً، نتحقق مما إذا كان الدرس معاينة مجانية (Preview)
|
||||
// Protected content requires a current student record. A preview flag
|
||||
// must not turn a missing/deleted student into a valid entitlement.
|
||||
if (!$student) {
|
||||
if ($lessonId) {
|
||||
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
if ($lesson && !empty($lesson['is_free_preview'])) {
|
||||
return [
|
||||
'allowed' => true,
|
||||
'reason' => 'free_preview',
|
||||
'student_grade' => 'guest',
|
||||
'is_sponsored' => false
|
||||
];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'allowed' => false,
|
||||
'reason' => 'unauthenticated_or_not_found',
|
||||
@@ -86,79 +76,71 @@ class StudentAccessControlService
|
||||
];
|
||||
}
|
||||
|
||||
$activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10');
|
||||
$activeStudentGrade = self::normalizeGrade((string)($student['grade_level'] ?? ''));
|
||||
$normalizedTargetGrade = self::normalizeGrade($targetGrade);
|
||||
|
||||
// Auto-heal obsolete database schema default:
|
||||
// If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10
|
||||
if ($allowSideEffects && ($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') {
|
||||
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [(int)$student['id']]);
|
||||
$student['grade_level'] = 'grade_10';
|
||||
$activeStudentGrade = 'grade_10';
|
||||
}
|
||||
|
||||
// 2. التحقق من قفل الصف الدراسي:
|
||||
// يطبق قفل الصف الصارم حصراً على طلبة المدارس الشريكة المشمولة (الثقافة العسكرية والمدارس المرتبطة بمديريات)
|
||||
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
|
||||
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
|
||||
if ($activeStudentGrade !== $normalizedTargetGrade) {
|
||||
if (!$isCohortLocked && !empty($student['id'])) {
|
||||
if ($allowSideEffects) {
|
||||
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
|
||||
}
|
||||
$activeStudentGrade = $normalizedTargetGrade;
|
||||
} else {
|
||||
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
|
||||
$currentGradeName = self::getGradeDisplayName($activeStudentGrade);
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'reason' => 'grade_mismatch',
|
||||
'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.",
|
||||
'student_grade' => $activeStudentGrade,
|
||||
'target_grade' => $normalizedTargetGrade,
|
||||
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. التحقق من النموذج المالي المزدوج:
|
||||
// أ. إذا كان الطالب تابعاً لمدارس الثقافة العسكرية أو مدرسة خاصة شريكة
|
||||
$isMilitaryCulture = ($student['directorate_type'] ?? '') === 'military_culture' || ($student['school_type'] ?? '') === 'military_culture';
|
||||
$isPrivateSponsored = !empty($student['is_school_sponsored']) && !empty($student['school_id']);
|
||||
|
||||
if ($isMilitaryCulture || $isPrivateSponsored) {
|
||||
// الطالب مشمول مجاناً 100% (صفر دينار)
|
||||
if ($allowSideEffects && $courseId && !empty($student['id'])) {
|
||||
self::ensureSchoolIncludedPass((int)$student['id'], $courseId);
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'reason' => 'school_sponsored_free',
|
||||
'message' => 'مشمول مجاناً عبر المنظومة المؤسسية الشريكة (مديرية الثقافة العسكرية / المدرسة المعتمدة).',
|
||||
'student_grade' => $activeStudentGrade,
|
||||
'is_sponsored' => true,
|
||||
'fee_jod' => 0.00
|
||||
'allowed' => false, 'reason' => 'grade_mismatch',
|
||||
'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.',
|
||||
'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade,
|
||||
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student)
|
||||
// A lesson read never changes grade or creates a school access pass.
|
||||
// Entitlements are checked against an actual course and school roster.
|
||||
if ($courseId) {
|
||||
// المساقات المجانية (السعر 0.00 دينار) متاحة فوراً ومجاناً للجميع
|
||||
$course = Database::selectOne("SELECT price_jod FROM courses WHERE id = ? LIMIT 1", [$courseId]);
|
||||
if ($course && (float)($course['price_jod'] ?? 0) <= 0.0) {
|
||||
$course = Database::selectOne(
|
||||
'SELECT c.id, c.school_id, c.is_school_exclusive, c.is_published, c.price_jod,
|
||||
c.grade_level, t.is_school_exclusive AS teacher_school_exclusive,
|
||||
t.is_marketplace_public
|
||||
FROM courses c JOIN teachers t ON t.id=c.teacher_id WHERE c.id=? LIMIT 1',
|
||||
[$courseId]
|
||||
);
|
||||
if (!$course || (int)$course['is_published'] !== 1) {
|
||||
return ['allowed' => false, 'reason' => 'course_unavailable', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
if ($course['grade_level'] !== null && $course['grade_level'] !== ''
|
||||
&& self::normalizeGrade((string)$course['grade_level']) !== $normalizedTargetGrade) {
|
||||
return ['allowed' => false, 'reason' => 'course_grade_mismatch', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
if ($lessonId) {
|
||||
$sourceLesson = Database::selectOne('SELECT course_id FROM lessons WHERE id=? LIMIT 1', [$lessonId]);
|
||||
if (!$sourceLesson || (int)$sourceLesson['course_id'] !== $courseId) {
|
||||
return ['allowed' => false, 'reason' => 'lesson_course_mismatch', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
}
|
||||
|
||||
$schoolId = (int)($course['school_id'] ?? 0);
|
||||
if ($schoolId > 0 || (int)$course['is_school_exclusive'] === 1 || (int)$course['teacher_school_exclusive'] === 1) {
|
||||
if ($schoolId <= 0 || (int)($student['school_id'] ?? 0) !== $schoolId
|
||||
|| empty($student['is_school_sponsored'])) {
|
||||
return ['allowed' => false, 'reason' => 'school_scope_mismatch', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
$roster = Database::selectOne(
|
||||
'SELECT grade_level FROM school_rosters WHERE school_id=? AND claimed_student_id=? AND is_claimed=1 LIMIT 1',
|
||||
[$schoolId, (int)$student['id']]
|
||||
);
|
||||
if (!$roster || self::normalizeGrade((string)$roster['grade_level']) !== $normalizedTargetGrade) {
|
||||
return ['allowed' => false, 'reason' => 'school_roster_unverified', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
return [
|
||||
'allowed' => true,
|
||||
'reason' => 'free_course',
|
||||
'student_grade' => $activeStudentGrade,
|
||||
'is_sponsored' => false
|
||||
'allowed' => true, 'reason' => 'school_course_included',
|
||||
'student_grade' => $activeStudentGrade, 'is_sponsored' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ((int)$course['is_marketplace_public'] !== 1) {
|
||||
return ['allowed' => false, 'reason' => 'not_marketplace_public', 'student_grade' => $activeStudentGrade];
|
||||
}
|
||||
if ((float)$course['price_jod'] <= 0.0) {
|
||||
return ['allowed' => true, 'reason' => 'free_course', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false];
|
||||
}
|
||||
|
||||
$activePass = Database::selectOne(
|
||||
"SELECT id, pass_type, expires_at, is_active FROM course_access_passes
|
||||
WHERE student_id = ? AND course_id = ? AND is_active = 1
|
||||
AND pass_type IN ('discounted_micro_pass','full_marketplace')
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
LIMIT 1",
|
||||
[(int)$student['id'], $courseId]
|
||||
@@ -174,7 +156,7 @@ class StudentAccessControlService
|
||||
];
|
||||
}
|
||||
|
||||
// فحص إذا كان الدرس معاينة مجانية
|
||||
// An explicit free preview is allowed only on a public course.
|
||||
if ($lessonId) {
|
||||
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
if ($lesson && !empty($lesson['is_free_preview'])) {
|
||||
@@ -279,30 +261,6 @@ class StudentAccessControlService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* التحقق من وجود تصريح مدرسي مجاني أو إنشاؤه تلقائياً لطلبة المدارس الشريكة
|
||||
*/
|
||||
private static function ensureSchoolIncludedPass(int $studentId, int $courseId): void
|
||||
{
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM course_access_passes WHERE student_id = ? AND course_id = ? LIMIT 1",
|
||||
[$studentId, $courseId]
|
||||
);
|
||||
|
||||
if (!$existing) {
|
||||
$course = Database::selectOne("SELECT teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
|
||||
$teacherId = (int)($course['teacher_id'] ?? 1);
|
||||
|
||||
Database::insert(
|
||||
"INSERT INTO course_access_passes
|
||||
(student_id, course_id, teacher_id, pass_type, price_paid_jod, expires_at, is_active)
|
||||
VALUES
|
||||
(?, ?, ?, 'school_included', 0.00, DATE_ADD(NOW(), INTERVAL 1 YEAR), 1)",
|
||||
[$studentId, $courseId, $teacherId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* توحيد أسماء ومفاتيح الصفوف
|
||||
*/
|
||||
|
||||
@@ -1,40 +1,135 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
use PDO;
|
||||
|
||||
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
|
||||
final class TeacherLedgerService {
|
||||
public static function balance(int $teacherId): array {
|
||||
$account=self::account($teacherId);
|
||||
$row=Database::selectOne('SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN amount_fils WHEN debit_account_id=? THEN -amount_fils ELSE 0 END),0) AS balance FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',[$account,$account,$account,$account]);
|
||||
$holds=Database::selectOne("SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",[$teacherId]);
|
||||
$gross=(int)($row['balance']??0);$held=(int)($holds['held']??0);
|
||||
return ['account_id'=>$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)];
|
||||
final class TeacherLedgerService
|
||||
{
|
||||
public static function balance(int $teacherId): array
|
||||
{
|
||||
// Balance reads must never create accounts or mutate financial state.
|
||||
$accountId = self::existingAccountId($teacherId);
|
||||
$gross = 0;
|
||||
if ($accountId !== null) {
|
||||
$row = Database::selectOne(
|
||||
'SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN CAST(amount_fils AS SIGNED) WHEN debit_account_id=? THEN -CAST(amount_fils AS SIGNED) ELSE 0 END),0) AS balance
|
||||
FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',
|
||||
[$accountId, $accountId, $accountId, $accountId]
|
||||
);
|
||||
$gross = (int)($row['balance'] ?? 0);
|
||||
}
|
||||
$holds = Database::selectOne(
|
||||
"SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",
|
||||
[$teacherId]
|
||||
);
|
||||
$held = (int)($holds['held'] ?? 0);
|
||||
return [
|
||||
'account_id' => $accountId,
|
||||
'gross_fils' => $gross,
|
||||
'held_fils' => $held,
|
||||
'available_fils' => max(0, $gross - $held),
|
||||
];
|
||||
}
|
||||
public static function placeWithdrawalHold(int $teacherId,int $amountFils,string $key):array {
|
||||
if($amountFils<=0||$amountFils>100000000||!preg_match('/^[A-Za-z0-9._:-]{16,128}$/',$key))return ['http_status'=>400,'status'=>'error','message'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try {
|
||||
$existing=Database::selectOne('SELECT uuid,amount_fils,status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',[$teacherId,$key]);
|
||||
if($existing){if((int)$existing['amount_fils']!==$amountFils)return self::finish($pdo,['http_status'=>409,'status'=>'idempotency_conflict','message'=>'استعمل المفتاح ذاته بمبلغ مختلف.']);return self::finish($pdo,['http_status'=>200,'status'=>$existing['status'],'withdrawal_hold_id'=>$existing['uuid'],'replayed'=>true]);}
|
||||
$balance=self::balance($teacherId);
|
||||
if($amountFils>$balance['available_fils'])return self::finish($pdo,['http_status'=>409,'status'=>'insufficient_available_balance','message'=>'الرصيد المتاح لا يغطي الحجز.','available_fils'=>$balance['available_fils']]);
|
||||
$uuid=self::uuid();Database::insert("INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",[$uuid,$teacherId,$amountFils,$key]);
|
||||
return self::finish($pdo,['http_status'=>201,'status'=>'held','withdrawal_hold_id'=>$uuid,'amount_fils'=>$amountFils]);
|
||||
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
|
||||
public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array
|
||||
{
|
||||
if ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000
|
||||
|| !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) {
|
||||
return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// This existing row serializes all holds for one teacher. Lock it
|
||||
// before the first consistent read, so the next hold sees this one.
|
||||
$teacher = Database::selectOne('SELECT id FROM teachers WHERE id=? FOR UPDATE', [$teacherId]);
|
||||
if (!$teacher) {
|
||||
return self::finish($pdo, ['http_status' => 404, 'status' => 'teacher_not_found']);
|
||||
}
|
||||
$existing = Database::selectOne(
|
||||
'SELECT uuid, amount_fils, status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',
|
||||
[$teacherId, $key]
|
||||
);
|
||||
if ($existing) {
|
||||
if ((int)$existing['amount_fils'] !== $amountFils) {
|
||||
return self::finish($pdo, [
|
||||
'http_status' => 409, 'status' => 'idempotency_conflict',
|
||||
'message' => 'استعمل المفتاح ذاته بمبلغ مختلف.',
|
||||
]);
|
||||
}
|
||||
return self::finish($pdo, [
|
||||
'http_status' => 200, 'status' => $existing['status'],
|
||||
'withdrawal_hold_id' => $existing['uuid'], 'replayed' => true,
|
||||
]);
|
||||
}
|
||||
$balance = self::balance($teacherId);
|
||||
if ($amountFils > $balance['available_fils']) {
|
||||
return self::finish($pdo, [
|
||||
'http_status' => 409, 'status' => 'insufficient_available_balance',
|
||||
'message' => 'الرصيد المتاح لا يغطي الحجز.',
|
||||
'available_fils' => $balance['available_fils'],
|
||||
]);
|
||||
}
|
||||
$uuid = self::uuid();
|
||||
Database::insert(
|
||||
"INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",
|
||||
[$uuid, $teacherId, $amountFils, $key]
|
||||
);
|
||||
return self::finish($pdo, [
|
||||
'http_status' => 201, 'status' => 'held',
|
||||
'withdrawal_hold_id' => $uuid, 'amount_fils' => $amountFils,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public static function releaseHold(int $teacherId,string $holdUuid,string $reason):array {
|
||||
if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.'];
|
||||
$changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]);
|
||||
return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.'];
|
||||
|
||||
public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($teacherId <= 0 || !self::isUuid($holdUuid) || $reason === '' || mb_strlen($reason) > 500) {
|
||||
return ['http_status' => 400, 'status' => 'error', 'message' => 'بيانات إلغاء الحجز غير صالحة.'];
|
||||
}
|
||||
$changed = Database::execute(
|
||||
"UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW(),release_reason=? WHERE uuid=? AND teacher_id=? AND status='held'",
|
||||
[$reason, $holdUuid, $teacherId]
|
||||
);
|
||||
return $changed === 1
|
||||
? ['http_status' => 200, 'status' => 'released', 'reason' => $reason]
|
||||
: ['http_status' => 409, 'status' => 'hold_not_releasable', 'message' => 'الحجز غير موجود أو تمت تسويته.'];
|
||||
}
|
||||
private static function account(int $teacherId):int {
|
||||
$existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]);
|
||||
if($existing)return(int)$existing['id'];
|
||||
Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]);
|
||||
return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0);
|
||||
|
||||
private static function existingAccountId(int $teacherId): ?int
|
||||
{
|
||||
$row = Database::selectOne(
|
||||
"SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",
|
||||
[$teacherId]
|
||||
);
|
||||
return $row ? (int)$row['id'] : null;
|
||||
}
|
||||
|
||||
private static function isUuid(string $value): bool
|
||||
{
|
||||
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);
|
||||
}
|
||||
|
||||
private static function uuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 15) | 64);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 63) | 128);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
}
|
||||
|
||||
private static function finish(PDO $pdo, array $result): array
|
||||
{
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
}
|
||||
private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
|
||||
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
|
||||
private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ final class TeacherSubmissionService {
|
||||
$submission = Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]);
|
||||
if ($submission && !$replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>409,'status'=>'existing_submission','message'=>'لديك حصة مسجلة لهذا الدرس. استخدم الاستبدال لإنشاء نسخة مرشحة.','submission_id'=>$submission['uuid'],'current_published_video_version_id'=>$submission['current_published_video_version_id'] ?: null]);
|
||||
if (!$submission && $replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_missing','لا توجد حصة حالية لاستبدالها.'));
|
||||
if ($submission && $replacement && empty($submission['current_published_video_version_id'])) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_unpublished','لا توجد نسخة منشورة حالية لاستبدالها.'));
|
||||
if ($submissionUuid && (!$submission || !hash_equals((string)$submission['uuid'],$submissionUuid))) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_mismatch','هدف الاستبدال لا يطابق حصة المعلم الحالية.'));
|
||||
if (!$submission) { Database::insert("INSERT INTO teacher_submissions (uuid,teacher_id,curriculum_lesson_id,status) VALUES (?,?,?,'draft')",[self::uuid(),$teacherId,$lesson['id']]); $submission=Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); }
|
||||
$next=(int)(Database::selectOne('SELECT COALESCE(MAX(version_number),0) n FROM video_versions WHERE teacher_submission_id=? FOR UPDATE',[$submission['id']])['n'] ?? 0)+1;
|
||||
@@ -30,13 +31,14 @@ final class TeacherSubmissionService {
|
||||
}
|
||||
public static function attachUploadedLesson(int $teacherId,string $versionUuid,int $lessonId,string $sha): void {
|
||||
if(!self::uuidValid($versionUuid)||!preg_match('/^[0-9a-f]{64}$/i',$sha)) throw new \RuntimeException('هوية النسخة أو بصمة الملف غير صالحة.');
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status='review' WHERE id=?",[$v['submission_id']]);$pdo->commit(); VideoReviewService::queueForVersion((int)$v['id']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status=CASE WHEN current_published_video_version_id IS NULL THEN 'review' ELSE 'published' END WHERE id=?",[$v['submission_id']]); VideoReviewService::queueForVersion((int)$v['id']); $pdo->commit(); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
public static function reserveUpload(int $teacherId, string $versionUuid): bool {
|
||||
if (!self::uuidValid($versionUuid)) return false;
|
||||
return Database::execute(
|
||||
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
||||
SET vv.status='uploading', ts.status='uploading'
|
||||
SET vv.status='uploading',
|
||||
ts.status=CASE WHEN ts.current_published_video_version_id IS NULL THEN 'uploading' ELSE 'published' END
|
||||
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status IN ('draft', 'uploading')",
|
||||
[$versionUuid, $teacherId]
|
||||
) >= 1;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Structural coverage gate. Content accuracy still requires academic review. */
|
||||
final class VideoCoverageValidator
|
||||
{
|
||||
public static function valid(array $coverage, int $durationSeconds, string $videoSha256): bool
|
||||
{
|
||||
if ($durationSeconds <= 0 || $durationSeconds > 4 * 60 * 60 ||
|
||||
!preg_match('/^[0-9a-f]{64}$/i', $videoSha256) ||
|
||||
!hash_equals(strtolower($videoSha256), strtolower((string)($coverage['video_sha256'] ?? ''))) ||
|
||||
($coverage['truncated'] ?? true) !== false ||
|
||||
!empty($coverage['gaps']) ||
|
||||
!is_array($coverage['segments'] ?? null) || $coverage['segments'] === []) {
|
||||
return false;
|
||||
}
|
||||
$lastEnd = 0.0;
|
||||
foreach ($coverage['segments'] as $segment) {
|
||||
if (!is_array($segment) ||
|
||||
!is_numeric($segment['start_seconds'] ?? null) ||
|
||||
!is_numeric($segment['end_seconds'] ?? null) ||
|
||||
!is_string($segment['transcript_ref'] ?? null) || trim($segment['transcript_ref']) === '' ||
|
||||
!is_string($segment['visual_ref'] ?? null) || trim($segment['visual_ref']) === '') {
|
||||
return false;
|
||||
}
|
||||
$start = (float)$segment['start_seconds'];
|
||||
$end = (float)$segment['end_seconds'];
|
||||
if (!is_finite($start) || !is_finite($end) ||
|
||||
abs($start - $lastEnd) > 0.5 || $end <= $start || $end > $durationSeconds + 0.5) {
|
||||
return false;
|
||||
}
|
||||
$lastEnd = $end;
|
||||
}
|
||||
return abs($lastEnd - $durationSeconds) <= 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** A human approval must not silently override missing or blocking findings. */
|
||||
final class VideoReviewReportValidator
|
||||
{
|
||||
public static function approvable(array $report): bool
|
||||
{
|
||||
$coverage = $report['coverage'] ?? null;
|
||||
if (!is_array($coverage) || ($coverage['truncated'] ?? true) !== false ||
|
||||
!array_key_exists('unprocessed_intervals', $coverage) || $coverage['unprocessed_intervals'] !== [] ||
|
||||
!is_array($coverage['section_results'] ?? null) || $coverage['section_results'] === [] ||
|
||||
!is_array($coverage['objective_results'] ?? null) || $coverage['objective_results'] === [] ||
|
||||
($report['missing_inputs'] ?? null) !== [] || !is_array($report['issues'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
foreach (['section_results', 'objective_results'] as $field) {
|
||||
foreach ($coverage[$field] as $result) {
|
||||
if (!is_array($result) || !in_array($result['status'] ?? null, ['covered', 'out_of_declared_scope'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (($result['status'] ?? '') === 'covered' &&
|
||||
empty($result['evidence_refs']) && empty($result['evidence_ref'])) {
|
||||
return false;
|
||||
}
|
||||
if (($result['status'] ?? '') === 'out_of_declared_scope' &&
|
||||
trim((string)($result['reason'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($report['issues'] as $issue) {
|
||||
if (!is_array($issue) ||
|
||||
(in_array($issue['severity'] ?? null, ['critical', 'major'], true) &&
|
||||
(($issue['resolution_status'] ?? '') !== 'resolved' ||
|
||||
trim((string)($issue['resolution_evidence'] ?? '')) === ''))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,24 @@ final class VideoReviewService
|
||||
}
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
|
||||
$job=Database::selectOne('SELECT j.id,j.status,j.video_sha256,j.markdown_sha256,vv.uuid AS video_version_uuid,cl.uuid AS curriculum_lesson_uuid,e.id AS evidence_id,e.coverage_json FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id JOIN curriculum_lessons cl ON cl.id=j.curriculum_lesson_id LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
|
||||
if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
|
||||
if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']);
|
||||
if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
|
||||
$bindings = $report['bindings'];
|
||||
$coverageReport = $report['coverage'];
|
||||
$savedCoverage = json_decode((string)$job['coverage_json'], true);
|
||||
if (!is_array($bindings) || !is_array($coverageReport) || !is_array($savedCoverage) ||
|
||||
!hash_equals((string)$job['video_version_uuid'], (string)($bindings['video_version_id'] ?? '')) ||
|
||||
!hash_equals((string)$job['curriculum_lesson_uuid'], (string)($bindings['curriculum_lesson_id'] ?? '')) ||
|
||||
!hash_equals((string)$job['video_sha256'], (string)($bindings['video_sha256'] ?? '')) ||
|
||||
!hash_equals((string)$job['markdown_sha256'], (string)($bindings['markdown_sha256'] ?? '')) ||
|
||||
($coverageReport['truncated'] ?? true) !== false || !empty($coverageReport['unprocessed_intervals'])) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'stale_or_incomplete_report','message'=>'ارتباطات التقرير أو تغطيته لا تطابق النسخة والمصدر.']);
|
||||
}
|
||||
if ($decision === 'approved' && !VideoReviewReportValidator::approvable($report)) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'blocking_findings','message'=>'تقرير الفحص لا يغطي كل الأقسام والأهداف أو يحتوي مشكلات غير محسومة.']);
|
||||
}
|
||||
$approved=$decision==='approved' && $recommendation==='ready_for_human_review';
|
||||
Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]);
|
||||
Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]);
|
||||
@@ -34,25 +48,24 @@ final class VideoReviewService
|
||||
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
|
||||
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
|
||||
}
|
||||
if (empty($coverage['segments']) || !is_array($coverage['segments'])) {
|
||||
return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.'];
|
||||
}
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]);
|
||||
$job=Database::selectOne('SELECT j.id,j.video_sha256,j.markdown_sha256,j.status,j.video_version_id,l.duration_seconds
|
||||
FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id
|
||||
JOIN lessons l ON l.id=vv.source_lesson_id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
|
||||
if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
|
||||
if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
|
||||
$transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]);
|
||||
$visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]);
|
||||
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') {
|
||||
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' ||
|
||||
$visual['asset_type'] !== 'other' || $visual['review_status'] !== 'approved' ||
|
||||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$transcript['source_reference']) ||
|
||||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$visual['source_reference'])) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
|
||||
}
|
||||
$segments=$coverage['segments']; $lastEnd=0;
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
|
||||
}
|
||||
$lastEnd=(float)$segment['end_seconds'];
|
||||
if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
|
||||
!hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']);
|
||||
}
|
||||
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
|
||||
Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]);
|
||||
@@ -66,25 +79,63 @@ final class VideoReviewService
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$job = Database::selectOne(
|
||||
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
|
||||
vv.source_lesson_id, ts.id AS submission_id,
|
||||
"SELECT j.id, j.status, j.video_version_id, j.video_sha256, j.markdown_sha256,
|
||||
vv.uuid AS version_uuid, vv.status AS version_status, vv.content_sha256,
|
||||
vv.source_lesson_id, vv.replaces_video_version_id,
|
||||
ts.id AS submission_id, ts.curriculum_lesson_id,
|
||||
ts.current_published_video_version_id, cl.source_status,
|
||||
l.encoding_status, l.duration_seconds,
|
||||
currentv.uuid AS current_version_uuid
|
||||
FROM video_review_jobs j
|
||||
JOIN video_versions vv ON vv.id = j.video_version_id
|
||||
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
|
||||
LEFT JOIN lessons l ON l.id=vv.source_lesson_id
|
||||
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
|
||||
WHERE j.uuid = ? FOR UPDATE",
|
||||
[$jobUuid]
|
||||
);
|
||||
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
|
||||
$report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
|
||||
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
|
||||
$report = Database::selectOne('SELECT human_decision,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
|
||||
$evidence = Database::selectOne('SELECT id,coverage_json FROM video_review_evidence WHERE video_review_job_id=? FOR UPDATE', [$job['id']]);
|
||||
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved' ||
|
||||
($report['recommendation'] ?? '') !== 'ready_for_human_review' || empty($report['reviewer_id']) || !$evidence) {
|
||||
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
|
||||
}
|
||||
$reportData = json_decode((string)$report['report_json'], true);
|
||||
$savedCoverage = json_decode((string)$evidence['coverage_json'], true);
|
||||
if (!is_array($reportData) || !is_array($savedCoverage) ||
|
||||
!VideoReviewReportValidator::approvable($reportData) ||
|
||||
!VideoCoverageValidator::valid($savedCoverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
|
||||
!hash_equals((string)$job['markdown_sha256'], (string)($savedCoverage['markdown_sha256'] ?? '')) ||
|
||||
($reportData['coverage']['truncated'] ?? true) !== false ||
|
||||
!empty($reportData['coverage']['unprocessed_intervals'])) {
|
||||
return self::finish($pdo, ['status'=>'stale_evidence','http_status'=>409,'message'=>'أدلة الفيديو أو التقرير لم تعد صالحة للنشر.']);
|
||||
}
|
||||
if (($job['version_status'] ?? '') !== 'review' || ($job['source_status'] ?? '') !== 'approved' ||
|
||||
($job['encoding_status'] ?? '') !== 'ready' || empty($job['source_lesson_id']) ||
|
||||
!hash_equals((string)$job['video_sha256'], (string)($job['content_sha256'] ?? '')) ||
|
||||
$job['video_sha256'] === str_repeat('0', 64) || $job['markdown_sha256'] === str_repeat('0', 64)) {
|
||||
return self::finish($pdo, ['status'=>'version_not_ready','http_status'=>409,'message'=>'نسخة الفيديو أو درسها غير جاهزين للنشر.']);
|
||||
}
|
||||
$markdown = Database::selectOne(
|
||||
"SELECT a.id FROM publication_bundles pb
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id
|
||||
WHERE pb.curriculum_lesson_id=? AND pb.status='published'
|
||||
AND a.sha256=? AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
LIMIT 1 FOR UPDATE",
|
||||
[(int)$job['curriculum_lesson_id'], $job['markdown_sha256']]
|
||||
);
|
||||
if (!$markdown) return self::finish($pdo, ['status'=>'source_changed','http_status'=>409,'message'=>'مصدر الدرس المعتمد لا يطابق تقرير الفحص.']);
|
||||
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
|
||||
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
|
||||
}
|
||||
if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']);
|
||||
if (($job['current_published_video_version_id'] === null) !== ($job['replaces_video_version_id'] === null) ||
|
||||
($job['current_published_video_version_id'] !== null && (int)$job['current_published_video_version_id'] !== (int)$job['replaces_video_version_id']) ||
|
||||
($job['current_published_video_version_id'] !== null && $expectedCurrentVersionUuid === null)) {
|
||||
return self::finish($pdo, ['status'=>'current_version_conflict','http_status'=>409,'message'=>'يلزم تحديد النسخة الحالية المطابقة قبل الاستبدال.']);
|
||||
}
|
||||
Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]);
|
||||
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]);
|
||||
Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]);
|
||||
|
||||
@@ -385,7 +385,7 @@ class VideoService
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Cache-Control: private, no-store');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Content-Length: ' . filesize($filePath));
|
||||
readfile($filePath);
|
||||
@@ -438,7 +438,7 @@ class VideoService
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
header('Cache-Control: private, no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Pure watch-v1 measurement rules. This measures playback evidence, not attention. */
|
||||
final class WatchIntervalPolicy
|
||||
{
|
||||
public static function evaluate(
|
||||
string $previousType,
|
||||
string $eventType,
|
||||
int $previousPositionMs,
|
||||
int $positionMs,
|
||||
int $serverDeltaSeconds,
|
||||
int $durationMs,
|
||||
string $fundingSource
|
||||
): array {
|
||||
if ($eventType !== 'heartbeat' || !in_array($previousType, ['start', 'heartbeat', 'resume'], true)) {
|
||||
return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'non_contiguous_event'];
|
||||
}
|
||||
if ($fundingSource === 'none') {
|
||||
return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'unfunded_session'];
|
||||
}
|
||||
if ($durationMs <= 0 || $positionMs > $durationMs) {
|
||||
return ['seconds' => 0, 'status' => 'review', 'reason' => 'position_out_of_bounds'];
|
||||
}
|
||||
$clientDeltaMs = $positionMs - $previousPositionMs;
|
||||
if ($clientDeltaMs < 1000 || $clientDeltaMs > 90000 || $serverDeltaSeconds < 1 || $serverDeltaSeconds > 90) {
|
||||
return ['seconds' => 0, 'status' => 'review', 'reason' => 'implausible_delta'];
|
||||
}
|
||||
$seconds = min((int)floor($clientDeltaMs / 1000), $serverDeltaSeconds, 60);
|
||||
if ($seconds <= 0) return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'zero_interval'];
|
||||
return ['seconds' => $seconds, 'status' => 'measured', 'reason' => 'contiguous_heartbeat'];
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,13 @@ final class WatchSessionService {
|
||||
if (!$row['allowed']) return $row;
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]);
|
||||
if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; }
|
||||
$student=Database::selectOne('SELECT id FROM students WHERE id=? FOR UPDATE',[$studentId]);
|
||||
if(!$student)return self::finish($pdo,['http_status'=>404,'status'=>'student_not_found']);
|
||||
$active=Database::selectOne("SELECT uuid,video_version_id FROM watch_sessions WHERE student_id=? AND status='active' ORDER BY id DESC LIMIT 1 FOR UPDATE",[$studentId]);
|
||||
if ($active) {
|
||||
if((int)$active['video_version_id']===(int)$row['version_id'])return self::finish($pdo,['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]);
|
||||
return self::finish($pdo,['http_status'=>409,'status'=>'another_session_active','message'=>'أنهِ جلسة المشاهدة الحالية قبل بدء حصة أخرى.']);
|
||||
}
|
||||
$uuid=self::uuid();
|
||||
Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]);
|
||||
$id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0);
|
||||
@@ -22,33 +27,35 @@ final class WatchSessionService {
|
||||
public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array {
|
||||
if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.'];
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try {
|
||||
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source FROM watch_sessions ws WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
|
||||
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source,l.duration_seconds FROM watch_sessions ws JOIN video_versions vv ON vv.id=ws.video_version_id JOIN lessons l ON l.id=vv.source_lesson_id WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
|
||||
if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']);
|
||||
if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']);
|
||||
$version = Database::selectOne('SELECT uuid FROM video_versions WHERE id=?', [(int)$session['video_version_id']]);
|
||||
$authorised = $version ? self::authorisedVersion($studentId, (string)$version['uuid'], null) : ['allowed'=>false];
|
||||
if (empty($authorised['allowed'])) {
|
||||
Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);
|
||||
return self::finish($pdo,['http_status'=>409,'status'=>'revoked','message'=>'انتهت صلاحية هذه المشاهدة.']);
|
||||
}
|
||||
$last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]);
|
||||
if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']);
|
||||
if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']);
|
||||
Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]);
|
||||
$eligible=0;
|
||||
if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) {
|
||||
$clientDelta=$positionMs-(int)$last['client_position_ms'];
|
||||
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
|
||||
// A contiguous interval is bounded by elapsed server time and 60s;
|
||||
// seeks, background bursts, and client-only minutes earn nothing.
|
||||
if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) {
|
||||
$eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60);
|
||||
if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']);
|
||||
}
|
||||
}
|
||||
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
|
||||
$decision=WatchIntervalPolicy::evaluate((string)$last['event_type'],$type,(int)$last['client_position_ms'],$positionMs,$serverDelta,max(0,(int)$session['duration_seconds'])*1000,(string)$session['funding_source']);
|
||||
$eligible=(int)$decision['seconds'];
|
||||
if($type==='heartbeat') Database::insert(
|
||||
"INSERT INTO eligible_watch_intervals (watch_session_id,event_sequence_no,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,?,'watch-v1',?)",
|
||||
[$session['id'],$sequence,(int)$last['client_position_ms'],$positionMs,$eligible,$decision['reason'],$decision['status']]
|
||||
);
|
||||
if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);}
|
||||
return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]);
|
||||
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
|
||||
if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.'];
|
||||
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,c.grade_level FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
|
||||
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,cl.grade_key FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 AND a.review_status='approved' AND a.rights_status='cleared' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
|
||||
if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.'];
|
||||
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_level']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
|
||||
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_key']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
|
||||
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.'];
|
||||
return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user