Fix student grade registration: set grade_10 as default, add auto-heal for legacy tawjihi_2008, add student grade selector and update-grade API
This commit is contained in:
@@ -20,7 +20,9 @@ class ErrorNotebookSummary {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory ErrorNotebookSummary.fromJson(Map<String, dynamic> json) {
|
factory ErrorNotebookSummary.fromJson(Map<String, dynamic> json) {
|
||||||
final rawBySubject = json['by_subject'] as Map<String, dynamic>? ?? {};
|
final rawBySubject = json['by_subject'] is Map
|
||||||
|
? Map<String, dynamic>.from(json['by_subject'] as Map)
|
||||||
|
: <String, dynamic>{};
|
||||||
final bySub = <String, int>{};
|
final bySub = <String, int>{};
|
||||||
rawBySubject.forEach((k, v) {
|
rawBySubject.forEach((k, v) {
|
||||||
bySub[k] = (v is num) ? v.toInt() : 0;
|
bySub[k] = (v is num) ? v.toInt() : 0;
|
||||||
|
|||||||
@@ -119,6 +119,27 @@ class AuthRepository {
|
|||||||
throw ApiException('فشل استكمال فتح الملف الأكاديمي');
|
throw ApiException('فشل استكمال فتح الملف الأكاديمي');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<UserModel> 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<String, dynamic> && res['data'] != null) {
|
||||||
|
final data = res['data'];
|
||||||
|
if (data['user'] != null) {
|
||||||
|
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||||
|
await _storage.saveUser(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ApiException('فشل تحديث الصف الدراسي');
|
||||||
|
}
|
||||||
|
|
||||||
Future<UserModel?> getMe() async {
|
Future<UserModel?> getMe() async {
|
||||||
try {
|
try {
|
||||||
final res = await _api.get(AppConfig.meEndpoint);
|
final res = await _api.get(AppConfig.meEndpoint);
|
||||||
|
|||||||
@@ -169,6 +169,22 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> switchRole(String newRole) async {
|
Future<void> switchRole(String newRole) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is Authenticated) {
|
if (currentState is Authenticated) {
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
final storageKey = _getLessonStorageKey(lesson);
|
final storageKey = _getLessonStorageKey(lesson);
|
||||||
|
|
||||||
try {
|
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) {
|
if (playback.videoUrl.isEmpty) {
|
||||||
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,16 +207,16 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
child: const Icon(CupertinoIcons.lock_shield_fill, color: AppColors.saqelCyan, size: 18),
|
child: const Icon(CupertinoIcons.lock_shield_fill, color: AppColors.saqelCyan, size: 18),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'الصف العاشر الأساسي — المسار الأكاديمي المعتمد',
|
'${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
|
||||||
),
|
),
|
||||||
SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
const Text(
|
||||||
'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور',
|
'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور',
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../widgets/luxury_widgets.dart';
|
|||||||
import '../curriculum/subjects_grid_screen.dart';
|
import '../curriculum/subjects_grid_screen.dart';
|
||||||
import '../notebook/smart_error_notebook_screen.dart';
|
import '../notebook/smart_error_notebook_screen.dart';
|
||||||
import '../vocational/vocational_training_screen.dart';
|
import '../vocational/vocational_training_screen.dart';
|
||||||
|
import '../../../data/repositories/curriculum_repository.dart';
|
||||||
|
|
||||||
class UnifiedHomeScreen extends StatefulWidget {
|
class UnifiedHomeScreen extends StatefulWidget {
|
||||||
final UserModel user;
|
final UserModel user;
|
||||||
@@ -38,6 +39,14 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
_refreshData();
|
_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() {
|
void _refreshData() {
|
||||||
if (_currentRole == 'student') {
|
if (_currentRole == 'student') {
|
||||||
context.read<StudentCubit>().fetchLessons(
|
context.read<StudentCubit>().fetchLessons(
|
||||||
@@ -57,6 +66,157 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
_refreshData();
|
_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<AuthCubit>().updateGrade(tempGrade, stream: tempStream);
|
||||||
|
if (mounted) {
|
||||||
|
_refreshData();
|
||||||
|
SaqelToast.showSuccess(
|
||||||
|
context,
|
||||||
|
'تم ضبط صفك بنجاح: ${GradeLevelModel.getGradeName(tempGrade)}',
|
||||||
|
title: 'تحديث المسار الدراسي ✨',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isStudent = _currentRole == 'student';
|
final isStudent = _currentRole == 'student';
|
||||||
@@ -129,10 +289,39 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
widget.user.name,
|
widget.user.name,
|
||||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(height: 3),
|
||||||
isStudent ? AppStrings.studentPortal : AppStrings.guardianPortal,
|
if (isStudent)
|
||||||
style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark),
|
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(),
|
const Spacer(),
|
||||||
@@ -466,18 +655,29 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
onTap: () => _showGradeSelectionModal(context),
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: AppColors.saqelCyan.withAlpha(30),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
borderRadius: BorderRadius.circular(6),
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(6),
|
||||||
widget.user.gradeLevel ?? 'توجيهي 2008',
|
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
|
||||||
style: const TextStyle(
|
),
|
||||||
fontSize: 11,
|
child: Row(
|
||||||
fontWeight: FontWeight.w700,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: AppColors.saqelCyan,
|
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),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -514,6 +514,10 @@ class AuthController
|
|||||||
[$userId]
|
[$userId]
|
||||||
);
|
);
|
||||||
if ($user) {
|
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']);
|
$isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']);
|
||||||
$userData = [
|
$userData = [
|
||||||
'id' => $user['id'],
|
'id' => $user['id'],
|
||||||
@@ -579,6 +583,11 @@ class AuthController
|
|||||||
$student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]);
|
$student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]);
|
||||||
|
|
||||||
if ($student) {
|
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.
|
// Student exists. Verify identity linkage.
|
||||||
if ($student['identity_id'] === null) {
|
if ($student['identity_id'] === null) {
|
||||||
// Pre-registered by Guardian, link to this identity phone now
|
// 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
|
* Logout and destroy Redis active session
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
|
|||||||
@@ -520,15 +520,20 @@ class VideoController
|
|||||||
CurriculumService::ensureSchema();
|
CurriculumService::ensureSchema();
|
||||||
|
|
||||||
$curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? ''));
|
$curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? ''));
|
||||||
|
$curriculumKeyNoExt = preg_replace('/\.md$/i', '', $curriculumKey);
|
||||||
$rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? '');
|
$rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? '');
|
||||||
$lesson = null;
|
$lesson = null;
|
||||||
|
|
||||||
if ($curriculumKey !== '') {
|
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) {
|
} elseif (is_numeric($rawId) && (int)$rawId > 0) {
|
||||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]);
|
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]);
|
||||||
} elseif (!empty($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_*),
|
// Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*),
|
||||||
// while the database stores the canonical lesson title.
|
// while the database stores the canonical lesson title.
|
||||||
if (!$lesson) {
|
if (!$lesson) {
|
||||||
|
|||||||
@@ -88,20 +88,35 @@ class StudentAccessControlService
|
|||||||
$activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10');
|
$activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10');
|
||||||
$normalizedTargetGrade = self::normalizeGrade($targetGrade);
|
$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) {
|
if ($activeStudentGrade !== $normalizedTargetGrade) {
|
||||||
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
|
if (!$isCohortLocked && !empty($student['id'])) {
|
||||||
$currentGradeName = self::getGradeDisplayName($activeStudentGrade);
|
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
|
||||||
|
$activeStudentGrade = $normalizedTargetGrade;
|
||||||
return [
|
} else {
|
||||||
'allowed' => false,
|
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
|
||||||
'reason' => 'grade_mismatch',
|
$currentGradeName = self::getGradeDisplayName($activeStudentGrade);
|
||||||
'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.",
|
|
||||||
'student_grade' => $activeStudentGrade,
|
return [
|
||||||
'target_grade' => $normalizedTargetGrade,
|
'allowed' => false,
|
||||||
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false)
|
'reason' => 'grade_mismatch',
|
||||||
];
|
'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.",
|
||||||
|
'student_grade' => $activeStudentGrade,
|
||||||
|
'target_grade' => $normalizedTargetGrade,
|
||||||
|
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false)
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. التحقق من النموذج المالي المزدوج:
|
// 3. التحقق من النموذج المالي المزدوج:
|
||||||
@@ -126,8 +141,18 @@ class StudentAccessControlService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student)
|
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student)
|
||||||
// يلزم وجود تصريح ساري مدفوع (عبر كليك أو غيره)
|
|
||||||
if ($courseId) {
|
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(
|
$activePass = Database::selectOne(
|
||||||
"SELECT id, pass_type, expires_at, is_active FROM course_access_passes
|
"SELECT id, pass_type, expires_at, is_active FROM course_access_passes
|
||||||
WHERE student_id = ? AND course_id = ? AND is_active = 1
|
WHERE student_id = ? AND course_id = ? AND is_active = 1
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ CREATE TABLE IF NOT EXISTS `school_rosters` (
|
|||||||
`national_id` TEXT NOT NULL,
|
`national_id` TEXT NOT NULL,
|
||||||
`national_id_hash` CHAR(64) NOT NULL,
|
`national_id_hash` CHAR(64) NOT NULL,
|
||||||
`student_full_name` VARCHAR(255) 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',
|
`stream` ENUM('scientific', 'literary', 'vocational', 'general') NOT NULL DEFAULT 'scientific',
|
||||||
`is_claimed` TINYINT(1) NOT NULL DEFAULT 0,
|
`is_claimed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`claimed_student_id` BIGINT UNSIGNED DEFAULT NULL,
|
`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,
|
`national_id_hash` CHAR(64) NOT NULL UNIQUE,
|
||||||
`full_name` VARCHAR(255) NOT NULL,
|
`full_name` VARCHAR(255) NOT NULL,
|
||||||
`pin_code_hash` VARCHAR(255) DEFAULT 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',
|
`stream` ENUM('scientific', 'literary', 'vocational', 'general') NOT NULL DEFAULT 'scientific',
|
||||||
`is_school_sponsored` TINYINT(1) NOT NULL DEFAULT 0,
|
`is_school_sponsored` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`readiness_score` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
`readiness_score` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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->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->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/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)
|
// Guardian Routes (Authenticated)
|
||||||
$router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware);
|
$router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
echo "🔧 Starting Saqel Student Grade Repair Engine...\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::query("ALTER TABLE students ALTER COLUMN grade_level SET DEFAULT 'grade_10'");
|
||||||
|
echo " ✓ Altered students.grade_level default to 'grade_10'\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo " ℹ Note on students alter: " . $e->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";
|
||||||
Reference in New Issue
Block a user