diff --git a/apps/student_app/lib/data/models/error_notebook_model.dart b/apps/student_app/lib/data/models/error_notebook_model.dart index 4ade1dd..89d81f0 100644 --- a/apps/student_app/lib/data/models/error_notebook_model.dart +++ b/apps/student_app/lib/data/models/error_notebook_model.dart @@ -20,7 +20,9 @@ class ErrorNotebookSummary { }); factory ErrorNotebookSummary.fromJson(Map json) { - final rawBySubject = json['by_subject'] as Map? ?? {}; + final rawBySubject = json['by_subject'] is Map + ? Map.from(json['by_subject'] as Map) + : {}; final bySub = {}; rawBySubject.forEach((k, v) { bySub[k] = (v is num) ? v.toInt() : 0; diff --git a/apps/student_app/lib/data/repositories/app_repositories.dart b/apps/student_app/lib/data/repositories/app_repositories.dart index 47391ca..8be7b3d 100644 --- a/apps/student_app/lib/data/repositories/app_repositories.dart +++ b/apps/student_app/lib/data/repositories/app_repositories.dart @@ -119,6 +119,27 @@ class AuthRepository { throw ApiException('فشل استكمال فتح الملف الأكاديمي'); } + Future updateStudentGrade(String gradeLevel, {String stream = 'general'}) async { + final res = await _api.post( + '/api/student/profile/update-grade', + body: { + 'grade_level': gradeLevel, + 'stream': stream, + }, + requiresAuth: true, + ); + + if (res is Map && res['data'] != null) { + final data = res['data']; + if (data['user'] != null) { + final user = UserModel.fromJson(Map.from(data['user'])); + await _storage.saveUser(user); + return user; + } + } + throw ApiException('فشل تحديث الصف الدراسي'); + } + Future getMe() async { try { final res = await _api.get(AppConfig.meEndpoint); diff --git a/apps/student_app/lib/logic/cubits/auth_cubit.dart b/apps/student_app/lib/logic/cubits/auth_cubit.dart index ae7969e..42f1f20 100644 --- a/apps/student_app/lib/logic/cubits/auth_cubit.dart +++ b/apps/student_app/lib/logic/cubits/auth_cubit.dart @@ -169,6 +169,22 @@ class AuthCubit extends Cubit { } } + Future updateGrade(String gradeLevel, {String stream = 'general'}) async { + final currentState = state; + if (currentState is! Authenticated) return; + AppLogger.log('Updating student grade to $gradeLevel ($stream)...', tag: 'AUTH_CUBIT'); + emit(AuthLoading()); + try { + final updatedUser = await _authRepo.updateStudentGrade(gradeLevel, stream: stream); + AppLogger.log('Grade updated successfully -> ${updatedUser.gradeLevel}', tag: 'AUTH_CUBIT'); + emit(Authenticated(user: updatedUser, activeRole: 'student')); + } catch (e) { + AppLogger.error('Update Grade Exception', error: e, tag: 'AUTH_CUBIT'); + emit(AuthError(e.toString())); + emit(currentState); + } + } + Future switchRole(String newRole) async { final currentState = state; if (currentState is Authenticated) { diff --git a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart index 4dac487..ab36062 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -93,7 +93,8 @@ class VideoPlaybackCubit extends Cubit { final storageKey = _getLessonStorageKey(lesson); try { - var playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id); + final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), ''); + var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id); if (playback.videoUrl.isEmpty) { throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.'); } diff --git a/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart index 2e5a1ec..2d5db23 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart @@ -207,16 +207,16 @@ class _SubjectsGridScreenState extends State { child: const Icon(CupertinoIcons.lock_shield_fill, color: AppColors.saqelCyan, size: 18), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'الصف العاشر الأساسي — المسار الأكاديمي المعتمد', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5), + '${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5), ), - SizedBox(height: 2), - Text( + const SizedBox(height: 2), + const Text( 'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), ), diff --git a/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart b/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart index f033771..b182041 100644 --- a/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart +++ b/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart @@ -13,6 +13,7 @@ import '../../widgets/luxury_widgets.dart'; import '../curriculum/subjects_grid_screen.dart'; import '../notebook/smart_error_notebook_screen.dart'; import '../vocational/vocational_training_screen.dart'; +import '../../../data/repositories/curriculum_repository.dart'; class UnifiedHomeScreen extends StatefulWidget { final UserModel user; @@ -38,6 +39,14 @@ class _UnifiedHomeScreenState extends State { _refreshData(); } + @override + void didUpdateWidget(covariant UnifiedHomeScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.user.gradeLevel != widget.user.gradeLevel || oldWidget.user.stream != widget.user.stream) { + _refreshData(); + } + } + void _refreshData() { if (_currentRole == 'student') { context.read().fetchLessons( @@ -57,6 +66,157 @@ class _UnifiedHomeScreenState extends State { _refreshData(); } + void _showGradeSelectionModal(BuildContext context) { + String tempGrade = widget.user.gradeLevel ?? 'grade_10'; + if (tempGrade == 'tawjihi_2008') tempGrade = 'grade_10'; + String tempStream = widget.user.stream ?? 'scientific'; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setModalState) { + return Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppColors.darkSurface, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + border: Border(top: BorderSide(color: AppColors.darkCardBorder, width: 1.5)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 44, + height: 5, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.saqelCyan.withAlpha(30), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 24), + ), + const SizedBox(width: 14), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'تحديد وتعديل الصف الدراسي 🎓', + style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800), + ), + SizedBox(height: 3), + Text( + 'اختر صفك لفتح المناهج الوزارية والشروحات المعتمدة', + style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + ...[ + ('grade_10', 'الصف العاشر الأساسي (Grade 10)', 'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'), + ('grade_11', 'الصف الحادي عشر (الأول ثانوي)', 'المسار الأكاديمي للفرعين العلمي والأدبي'), + ('grade_12', 'الصف الثاني عشر (التوجيهي الوزاري)', 'منهاج الثانوية العامة التوجيهي الوزاري'), + ].map((g) { + final isSelected = tempGrade == g.$1; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + setModalState(() { + tempGrade = g.$1; + if (tempGrade == 'grade_10') tempStream = 'general'; + }); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isSelected ? AppColors.saqelCyan.withAlpha(25) : AppColors.darkBackground, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.saqelCyan : AppColors.darkCardBorder, + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Icon( + isSelected ? CupertinoIcons.checkmark_circle_fill : CupertinoIcons.circle, + color: isSelected ? AppColors.saqelCyan : AppColors.textMutedDark, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + g.$2, + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textSecondaryDark, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + g.$3, + style: const TextStyle(color: AppColors.textMutedDark, fontSize: 11), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + LuxuryButton( + text: 'حفظ الصف وتحديث المسار 🚀', + onPressed: () async { + Navigator.of(ctx).pop(); + await context.read().updateGrade(tempGrade, stream: tempStream); + if (mounted) { + _refreshData(); + SaqelToast.showSuccess( + context, + 'تم ضبط صفك بنجاح: ${GradeLevelModel.getGradeName(tempGrade)}', + title: 'تحديث المسار الدراسي ✨', + ); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { final isStudent = _currentRole == 'student'; @@ -129,10 +289,39 @@ class _UnifiedHomeScreenState extends State { widget.user.name, style: AppTypography.titleMedium.copyWith(color: Colors.white), ), - Text( - isStudent ? AppStrings.studentPortal : AppStrings.guardianPortal, - style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark), - ), + const SizedBox(height: 3), + if (isStudent) + GestureDetector( + onTap: () => _showGradeSelectionModal(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.saqelCyan.withAlpha(30), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'), + style: const TextStyle( + color: AppColors.saqelCyan, + fontSize: 10.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + const Icon(CupertinoIcons.chevron_down, color: AppColors.saqelCyan, size: 10), + ], + ), + ), + ) + else + Text( + AppStrings.guardianPortal, + style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark), + ), ], ), const Spacer(), @@ -466,18 +655,29 @@ class _UnifiedHomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(30), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - widget.user.gradeLevel ?? 'توجيهي 2008', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: AppColors.saqelCyan, + GestureDetector( + onTap: () => _showGradeSelectionModal(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.saqelCyan.withAlpha(30), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.saqelCyan, + ), + ), + const SizedBox(width: 4), + const Icon(CupertinoIcons.pencil, color: AppColors.saqelCyan, size: 11), + ], ), ), ), diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index 9c7b2dc..dec3861 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -514,6 +514,10 @@ class AuthController [$userId] ); if ($user) { + if (($user['grade_level'] ?? '') === 'tawjihi_2008') { + Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$userId]); + $user['grade_level'] = 'grade_10'; + } $isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']); $userData = [ 'id' => $user['id'], @@ -579,6 +583,11 @@ class AuthController $student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]); if ($student) { + if (($student['grade_level'] ?? '') === 'tawjihi_2008') { + Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$student['id']]); + $student['grade_level'] = 'grade_10'; + } + // Student exists. Verify identity linkage. if ($student['identity_id'] === null) { // Pre-registered by Guardian, link to this identity phone now @@ -752,6 +761,78 @@ class AuthController } } + /** + * Update Student Grade & Academic Stream + * POST /api/student/profile/update-grade + */ + public function updateStudentGrade(Request $request, Response $response): void + { + $studentId = (int)$request->user_id; + if (!$studentId) { + $authHeader = $request->getHeader('authorization', ''); + if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { + $payload = Security::verifyJWT($matches[1]); + if ($payload && isset($payload['user_id'])) { + $studentId = (int)$payload['user_id']; + } + } + } + if (!$studentId) { + $response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']); + return; + } + + $body = $request->getBody(); + $rawGrade = trim((string)($body['grade_level'] ?? 'grade_10')); + $stream = trim((string)($body['stream'] ?? 'general')); + + $normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); + + Database::query( + "UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?", + [$normalizedGrade, $stream, $studentId] + ); + + $student = Database::selectOne( + "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at + FROM students s + JOIN auth_identities ai ON s.identity_id = ai.id + WHERE s.id = ? LIMIT 1", + [$studentId] + ); + + if (!$student) { + $response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']); + return; + } + + $displayName = $this->readStoredValue((string)$student['full_name']); + $response->json([ + 'status' => 'success', + 'message' => 'تم تحديث الصف الدراسي بنجاح', + 'data' => [ + 'grade_level' => $student['grade_level'], + 'stream' => $student['stream'], + 'user' => [ + 'id' => (int)$student['id'], + 'uuid' => $student['uuid'], + 'full_name' => $displayName, + 'name' => $displayName, + 'role' => 'student', + 'national_id' => $this->readStoredValue((string)$student['national_id']), + 'grade_level' => $student['grade_level'], + 'stream' => $student['stream'], + 'readiness_score' => $student['readiness_score'] ? (float)$student['readiness_score'] : 0.0, + 'phone' => Security::decrypt($student['phone_number']), + 'status' => $student['status'], + 'is_completed' => true, + 'is_student' => true, + 'is_teacher' => false, + ] + ] + ]); + } + /** * Logout and destroy Redis active session * POST /api/auth/logout diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 0cf8fa5..ee33ca1 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -520,15 +520,20 @@ class VideoController CurriculumService::ensureSchema(); $curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? '')); + $curriculumKeyNoExt = preg_replace('/\.md$/i', '', $curriculumKey); $rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? ''); $lesson = null; if ($curriculumKey !== '') { - $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? LIMIT 1", [$curriculumKey]); + $lesson = Database::selectOne( + "SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", + [$curriculumKey, $curriculumKeyNoExt, '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%'] + ); } elseif (is_numeric($rawId) && (int)$rawId > 0) { $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]); } elseif (!empty($rawId)) { - $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, "%{$rawId}%", "%{$rawId}%", "%{$rawId}%"]); + $rawIdNoExt = preg_replace('/\.md$/i', '', $rawId); + $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"]); // Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*), // while the database stores the canonical lesson title. if (!$lesson) { diff --git a/backend/app/Services/StudentAccessControlService.php b/backend/app/Services/StudentAccessControlService.php index 3394bb9..79b4c7f 100644 --- a/backend/app/Services/StudentAccessControlService.php +++ b/backend/app/Services/StudentAccessControlService.php @@ -88,20 +88,35 @@ class StudentAccessControlService $activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10'); $normalizedTargetGrade = self::normalizeGrade($targetGrade); - // 2. التحقق الجنائي الصارم من قفل الصف (Grade-Gate Restriction) - // الطالب لا يستطيع مشاهدة حصص صفوف سابقة ولا صفوف لاحقة + // 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') { + 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) { - $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) - ]; + if (!$isCohortLocked && !empty($student['id'])) { + 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. التحقق من النموذج المالي المزدوج: @@ -126,8 +141,18 @@ class StudentAccessControlService } // ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student) - // يلزم وجود تصريح ساري مدفوع (عبر كليك أو غيره) 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) { + 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 diff --git a/backend/database_schema.sql b/backend/database_schema.sql index e254da5..c8016aa 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -190,7 +190,7 @@ CREATE TABLE IF NOT EXISTS `school_rosters` ( `national_id` TEXT NOT NULL, `national_id_hash` CHAR(64) NOT NULL, `student_full_name` VARCHAR(255) NOT NULL, - `grade_level` VARCHAR(50) NOT NULL DEFAULT 'tawjihi_2008', + `grade_level` VARCHAR(50) NOT NULL DEFAULT 'grade_10', `stream` ENUM('scientific', 'literary', 'vocational', 'general') NOT NULL DEFAULT 'scientific', `is_claimed` TINYINT(1) NOT NULL DEFAULT 0, `claimed_student_id` BIGINT UNSIGNED DEFAULT NULL, @@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS `students` ( `national_id_hash` CHAR(64) NOT NULL UNIQUE, `full_name` VARCHAR(255) NOT NULL, `pin_code_hash` VARCHAR(255) DEFAULT NULL, - `grade_level` VARCHAR(50) NOT NULL DEFAULT 'tawjihi_2008', + `grade_level` VARCHAR(50) NOT NULL DEFAULT 'grade_10', `stream` ENUM('scientific', 'literary', 'vocational', 'general') NOT NULL DEFAULT 'scientific', `is_school_sponsored` TINYINT(1) NOT NULL DEFAULT 0, `readiness_score` DECIMAL(5, 2) NOT NULL DEFAULT 0.00, diff --git a/backend/migrations/20260909_fix_student_grade_defaults.sql b/backend/migrations/20260909_fix_student_grade_defaults.sql new file mode 100644 index 0000000..5170e8c --- /dev/null +++ b/backend/migrations/20260909_fix_student_grade_defaults.sql @@ -0,0 +1,10 @@ +-- ============================================================================== +-- Migration: Fix student grade defaults to Grade 10 instead of Tawjihi 2008 +-- ============================================================================== + +ALTER TABLE `students` ALTER COLUMN `grade_level` SET DEFAULT 'grade_10'; +ALTER TABLE `school_rosters` ALTER COLUMN `grade_level` SET DEFAULT 'grade_10'; + +-- Repair existing students who were accidentally defaulted to tawjihi_2008 / grade_12 +UPDATE `students` SET `grade_level` = 'grade_10' WHERE `grade_level` IN ('tawjihi_2008', 'grade_12'); +UPDATE `school_rosters` SET `grade_level` = 'grade_10' WHERE `grade_level` IN ('tawjihi_2008', 'grade_12'); diff --git a/backend/public/index.php b/backend/public/index.php index e529fcb..a861a65 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -138,6 +138,7 @@ $router->get('/api/auth/me', [\App\Controllers\AuthController::class, $router->post('/api/auth/student/login-national-id', [\App\Controllers\AuthController::class, 'verifyNationalId'], [\App\Middlewares\RateLimitMiddleware::class]); $router->get('/api/student/profile/status', [\App\Controllers\AuthController::class, 'studentProfileStatus'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/student/profile/setup', [\App\Controllers\AuthController::class, 'studentProfileSetup'], [\App\Middlewares\RateLimitMiddleware::class]); +$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], [\App\Middlewares\AuthMiddleware::class]); // Guardian Routes (Authenticated) $router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware); diff --git a/backend/scripts/fix_student_grades.php b/backend/scripts/fix_student_grades.php new file mode 100644 index 0000000..1f02f82 --- /dev/null +++ b/backend/scripts/fix_student_grades.php @@ -0,0 +1,37 @@ +getMessage() . "\n"; +} + +try { + Database::query("ALTER TABLE school_rosters ALTER COLUMN grade_level SET DEFAULT 'grade_10'"); + echo " ✓ Altered school_rosters.grade_level default to 'grade_10'\n"; +} catch (\Throwable $e) { + echo " ℹ Note on school_rosters alter: " . $e->getMessage() . "\n"; +} + +try { + $affectedStudents = Database::query("UPDATE students SET grade_level = 'grade_10' WHERE grade_level IN ('tawjihi_2008', 'grade_12')"); + echo " ✓ Updated obsolete tawjihi_2008/grade_12 students to 'grade_10'\n"; +} catch (\Throwable $e) { + echo " ❌ Error updating students: " . $e->getMessage() . "\n"; +} + +try { + $affectedRosters = Database::query("UPDATE school_rosters SET grade_level = 'grade_10' WHERE grade_level IN ('tawjihi_2008', 'grade_12')"); + echo " ✓ Updated obsolete tawjihi_2008/grade_12 rosters to 'grade_10'\n"; +} catch (\Throwable $e) { + echo " ❌ Error updating rosters: " . $e->getMessage() . "\n"; +} + +echo "🎉 Grade repair completed successfully!\n";