bool, reason => string, student_grade => string, is_sponsored => bool] */ public static function validateLessonAccess( ?int $studentId, ?string $nationalId, string $targetGrade, ?int $courseId = null, ?int $lessonId = null, bool $allowSideEffects = false ): array { // 1. استرجاع بيانات الطالب وسجله الدراسي $student = null; if ($studentId) { $student = Database::selectOne( "SELECT s.*, sch.name as school_name, sch.type as school_type, sch.directorate_id, d.type as directorate_type FROM students s LEFT JOIN schools sch ON s.school_id = sch.id LEFT JOIN directorates d ON sch.directorate_id = d.id WHERE s.id = ? LIMIT 1", [$studentId] ); } elseif (!empty($nationalId)) { $student = Database::selectOne( "SELECT s.*, sch.name as school_name, sch.type as school_type, sch.directorate_id, d.type as directorate_type FROM students s LEFT JOIN schools sch ON s.school_id = sch.id LEFT JOIN directorates d ON sch.directorate_id = d.id WHERE s.national_id = ? LIMIT 1", [$nationalId] ); } // Protected content requires a current student record. A preview flag // must not turn a missing/deleted student into a valid entitlement. if (!$student) { return [ 'allowed' => false, 'reason' => 'unauthenticated_or_not_found', 'message' => 'يرجى تسجيل الدخول أو إدخال الرقم الوطني للتحقق من الصلاحية الدراسية.', 'student_grade' => 'unknown', 'is_sponsored' => false ]; } $activeStudentGrade = self::normalizeGrade((string)($student['grade_level'] ?? '')); $normalizedTargetGrade = self::normalizeGrade($targetGrade); if ($activeStudentGrade !== $normalizedTargetGrade) { return [ 'allowed' => false, 'reason' => 'grade_mismatch', 'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.', 'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade, 'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false), ]; } // A lesson read never changes grade or creates a school access pass. // Entitlements are checked against an actual course and school roster. if ($courseId) { $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' => '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] ); if ($activePass) { return [ 'allowed' => true, 'reason' => 'paid_pass_active', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false, 'pass_id' => $activePass['id'] ]; } // 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'])) { return [ 'allowed' => true, 'reason' => 'free_preview', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false ]; } } return [ 'allowed' => false, 'reason' => 'payment_required', 'message' => 'يتطلب هذا المساق تفعيل الاشتراك عبر نظام الدفع الفوري (CliQ) للطلبة المستقلين خارج المدارس الشريكة.', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false, 'course_id' => $courseId ]; } // افتراضياً لدروس المنهاج المعتمد لنفس الصف return [ 'allowed' => true, 'reason' => 'curriculum_default', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false ]; } /** * ترفيع الطالب سنوياً إلى الصف اللاحق (مثال: من خامس إلى سادس) * وإلغاء صلاحية الوصول لحصص الصفوف السابقة */ public static function promoteStudent(int $studentId, string $newGradeLevel): array { $student = Database::selectOne("SELECT id, full_name, grade_level, national_id FROM students WHERE id = ? LIMIT 1", [$studentId]); if (!$student) { return ['status' => 'error', 'message' => 'الطالب غير موجود']; } $oldGrade = self::normalizeGrade($student['grade_level']); $promotedGrade = self::normalizeGrade($newGradeLevel); Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$promotedGrade, $studentId]); Database::query("UPDATE school_rosters SET grade_level = ? WHERE national_id = ?", [$promotedGrade, $student['national_id']]); // إلغاء تفعيل التصاريح السابقة الخاصة بالصف المنتهي Database::query( "UPDATE course_access_passes cap JOIN courses c ON cap.course_id = c.id SET cap.is_active = 0 WHERE cap.student_id = ? AND c.grade_level = ?", [$studentId, $oldGrade] ); $oldName = self::getGradeDisplayName($oldGrade); $newName = self::getGradeDisplayName($promotedGrade); return [ 'status' => 'success', 'student_id' => $studentId, 'student_name' => $student['full_name'], 'old_grade' => $oldGrade, 'promoted_grade' => $promotedGrade, 'message' => "تم ترفيع الطالب ({$student['full_name']}) بنجاح من ({$oldName}) إلى ({$newName}) وتحديث الصلاحيات." ]; } /** * ترفيع كشف المدرسة بالكامل دفعة واحدة (Batch Grade Promotion) */ public static function batchPromoteRoster(int $schoolId, string $fromGrade, string $toGrade): array { $from = self::normalizeGrade($fromGrade); $to = self::normalizeGrade($toGrade); $count = (int)Database::selectOne( "SELECT COUNT(*) as cnt FROM students WHERE school_id = ? AND grade_level = ?", [$schoolId, $from] )['cnt']; Database::query( "UPDATE students SET grade_level = ?, updated_at = NOW() WHERE school_id = ? AND grade_level = ?", [$to, $schoolId, $from] ); Database::query( "UPDATE school_rosters SET grade_level = ? WHERE school_id = ? AND grade_level = ?", [$to, $schoolId, $from] ); return [ 'status' => 'success', 'school_id' => $schoolId, 'from_grade' => $from, 'to_grade' => $to, 'promoted_count' => $count, 'message' => "تم ترفيع {$count} طالباً في المدرسة بنجاح وتحديث صلاحياتهم الأكاديمية." ]; } /** * توحيد أسماء ومفاتيح الصفوف */ public static function normalizeGrade(string $grade): string { $g = strtolower(trim($grade)); if (str_contains($g, 'عاشر') || str_contains($g, 'grade_10') || str_contains($g, 'grade10') || str_contains($g, 'grade 10') || $g === '10') return 'grade_10'; if (str_contains($g, 'تاسع') || str_contains($g, 'grade_9') || str_contains($g, 'grade9') || str_contains($g, 'grade 9') || $g === '9') return 'grade_9'; if (str_contains($g, 'ثامن') || str_contains($g, 'grade_8') || str_contains($g, 'grade8') || str_contains($g, 'grade 8') || $g === '8') return 'grade_8'; if (str_contains($g, 'سابع') || str_contains($g, 'grade_7') || str_contains($g, 'grade7') || str_contains($g, 'grade 7') || $g === '7') return 'grade_7'; if (str_contains($g, 'سادس') || str_contains($g, 'grade_6') || str_contains($g, 'grade6') || str_contains($g, 'grade 6') || $g === '6') return 'grade_6'; if (str_contains($g, 'خامس') || str_contains($g, 'grade_5') || str_contains($g, 'grade5') || str_contains($g, 'grade 5') || $g === '5') return 'grade_5'; if (str_contains($g, 'حادي') || str_contains($g, 'أول ثانوي') || str_contains($g, 'grade_11') || str_contains($g, 'grade 11') || $g === '11') return 'grade_11'; if (str_contains($g, 'ثاني عشر') || str_contains($g, 'توجيهي') || str_contains($g, 'tawjihi') || str_contains($g, 'grade_12') || str_contains($g, 'grade 12') || $g === '12') return 'grade_12'; return $g; } /** * اسم العرض للصف بالعربية */ public static function getGradeDisplayName(string $gradeKey): string { return match ($gradeKey) { 'grade_5' => 'الصف الخامس الأساسي', 'grade_6' => 'الصف السادس الأساسي', 'grade_7' => 'الصف السابع الأساسي', 'grade_8' => 'الصف الثامن الأساسي', 'grade_9' => 'الصف التاسع الأساسي', 'grade_10' => 'الصف العاشر الأساسي', 'grade_11' => 'الصف الحادي عشر (الأول ثانوي)', 'grade_12', 'tawjihi_2008' => 'الصف الثاني عشر (التوجيهي الوزاري)', default => $gradeKey, }; } }