Harden published curriculum and student flows
This commit is contained in:
@@ -6,13 +6,57 @@ import 'package:flutter/foundation.dart';
|
|||||||
class AppLogger {
|
class AppLogger {
|
||||||
AppLogger._();
|
AppLogger._();
|
||||||
|
|
||||||
|
static const _sensitiveKeyParts = [
|
||||||
|
'authorization',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'password',
|
||||||
|
'phone',
|
||||||
|
'national',
|
||||||
|
'identity',
|
||||||
|
'otp',
|
||||||
|
'ticket',
|
||||||
|
'email',
|
||||||
|
'fingerprint',
|
||||||
|
'pin',
|
||||||
|
'address',
|
||||||
|
];
|
||||||
|
|
||||||
|
static bool _isSensitiveKey(String key) {
|
||||||
|
final normalized = key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||||
|
return _sensitiveKeyParts.any(normalized.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
static dynamic _safeLogValue(dynamic value, {String key = ''}) {
|
||||||
|
if (_isSensitiveKey(key)) return '[REDACTED]';
|
||||||
|
if (value is Map) {
|
||||||
|
return value.map((k, v) => MapEntry(
|
||||||
|
k.toString(),
|
||||||
|
_safeLogValue(v, key: k.toString()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
return value.map((v) => _safeLogValue(v)).toList();
|
||||||
|
}
|
||||||
|
if (value is String && key.isEmpty) return '[TEXT OMITTED]';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri _safeUri(Uri uri) {
|
||||||
|
final safeQuery = Map<String, String>.fromEntries(
|
||||||
|
uri.queryParameters.entries.where((entry) => !_isSensitiveKey(entry.key)),
|
||||||
|
);
|
||||||
|
return uri.replace(queryParameters: safeQuery.isEmpty ? null : safeQuery);
|
||||||
|
}
|
||||||
|
|
||||||
static void log(String message, {String tag = 'SAQEL'}) {
|
static void log(String message, {String tag = 'SAQEL'}) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[$tag] $message');
|
debugPrint('[$tag] $message');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void event(String name, {Map<String, dynamic>? details, String tag = 'EVENT'}) {
|
static void event(String name,
|
||||||
|
{Map<String, dynamic>? details, String tag = 'EVENT'}) {
|
||||||
if (!kDebugMode) return;
|
if (!kDebugMode) return;
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.write('⚡ [$tag] $name');
|
buffer.write('⚡ [$tag] $name');
|
||||||
@@ -35,21 +79,23 @@ class AppLogger {
|
|||||||
if (!kDebugMode) return;
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln('\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────');
|
||||||
buffer.writeln('➡️ Method: $method');
|
buffer.writeln('➡️ Method: $method');
|
||||||
buffer.writeln('🔗 URL: $uri');
|
buffer.writeln('🔗 URL: ${_safeUri(uri)}');
|
||||||
if (headers != null && headers.isNotEmpty) {
|
if (headers != null && headers.isNotEmpty) {
|
||||||
final safeHeaders = Map<String, String>.from(headers);
|
final safeHeaders = Map<String, String>.from(headers);
|
||||||
if (safeHeaders.containsKey('Authorization')) {
|
if (safeHeaders.containsKey('Authorization')) {
|
||||||
final auth = safeHeaders['Authorization']!;
|
safeHeaders['Authorization'] = '[REDACTED]';
|
||||||
safeHeaders['Authorization'] = auth.length > 20 ? '${auth.substring(0, 15)}...' : 'Bearer [REDACTED]';
|
|
||||||
}
|
}
|
||||||
|
safeHeaders.removeWhere((key, _) => _isSensitiveKey(key));
|
||||||
buffer.writeln('📋 Headers: ${jsonEncode(safeHeaders)}');
|
buffer.writeln('📋 Headers: ${jsonEncode(safeHeaders)}');
|
||||||
}
|
}
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
buffer.writeln('📦 Body: ${body is String ? body : jsonEncode(body)}');
|
buffer.writeln('📦 Body: ${jsonEncode(_safeLogValue(body))}');
|
||||||
}
|
}
|
||||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'───────────────────────────────────────────────────────────────');
|
||||||
debugPrint(buffer.toString());
|
debugPrint(buffer.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,24 +112,28 @@ class AppLogger {
|
|||||||
final emoji = isSuccess ? '✅' : '⚠️';
|
final emoji = isSuccess ? '✅' : '⚠️';
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
buffer.writeln('\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────');
|
||||||
buffer.writeln('⬅️ Method: $method');
|
buffer.writeln('⬅️ Method: $method');
|
||||||
buffer.writeln('🔗 URL: $uri');
|
buffer.writeln('🔗 URL: ${_safeUri(uri)}');
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
buffer.writeln('⏱️ Time: ${duration.inMilliseconds}ms');
|
buffer.writeln('⏱️ Time: ${duration.inMilliseconds}ms');
|
||||||
}
|
}
|
||||||
if (responseBody != null) {
|
if (responseBody != null) {
|
||||||
buffer.writeln('📦 Data: ${responseBody is String ? responseBody : jsonEncode(responseBody)}');
|
buffer.writeln('📦 Data: ${jsonEncode(_safeLogValue(responseBody))}');
|
||||||
}
|
}
|
||||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'───────────────────────────────────────────────────────────────');
|
||||||
debugPrint(buffer.toString());
|
debugPrint(buffer.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void error(String message, {dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) {
|
static void error(String message,
|
||||||
|
{dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) {
|
||||||
if (!kDebugMode) return;
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln('\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────');
|
||||||
buffer.writeln('❌ Message: $message');
|
buffer.writeln('❌ Message: $message');
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
buffer.writeln('⚠️ Error: $error');
|
buffer.writeln('⚠️ Error: $error');
|
||||||
@@ -91,7 +141,8 @@ class AppLogger {
|
|||||||
if (stackTrace != null) {
|
if (stackTrace != null) {
|
||||||
buffer.writeln('📍 Trace: \n$stackTrace');
|
buffer.writeln('📍 Trace: \n$stackTrace');
|
||||||
}
|
}
|
||||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
buffer.writeln(
|
||||||
|
'───────────────────────────────────────────────────────────────');
|
||||||
debugPrint(buffer.toString());
|
debugPrint(buffer.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class ErrorNotebookItem {
|
|||||||
final String errorCategory; // 'conceptual', 'calculation', 'rushed', 'misinterpretation'
|
final String errorCategory; // 'conceptual', 'calculation', 'rushed', 'misinterpretation'
|
||||||
final String status; // 'pending_remediation', 'in_remediation', 'mastered'
|
final String status; // 'pending_remediation', 'in_remediation', 'mastered'
|
||||||
final int remediationAttemptsCount;
|
final int remediationAttemptsCount;
|
||||||
|
final bool retentionIsDue;
|
||||||
final String? masteredAt;
|
final String? masteredAt;
|
||||||
final String createdAt;
|
final String createdAt;
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class ErrorNotebookItem {
|
|||||||
required this.errorCategory,
|
required this.errorCategory,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.remediationAttemptsCount,
|
required this.remediationAttemptsCount,
|
||||||
|
this.retentionIsDue = false,
|
||||||
this.masteredAt,
|
this.masteredAt,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
@@ -125,6 +127,7 @@ class ErrorNotebookItem {
|
|||||||
errorCategory: json['error_category']?.toString() ?? 'conceptual',
|
errorCategory: json['error_category']?.toString() ?? 'conceptual',
|
||||||
status: json['status']?.toString() ?? 'pending_remediation',
|
status: json['status']?.toString() ?? 'pending_remediation',
|
||||||
remediationAttemptsCount: (json['remediation_attempts_count'] as num?)?.toInt() ?? 0,
|
remediationAttemptsCount: (json['remediation_attempts_count'] as num?)?.toInt() ?? 0,
|
||||||
|
retentionIsDue: json['retention_is_due'] == true || json['retention_is_due'] == 1 || json['retention_is_due'] == '1',
|
||||||
masteredAt: json['mastered_at']?.toString(),
|
masteredAt: json['mastered_at']?.toString(),
|
||||||
createdAt: json['created_at']?.toString() ?? '',
|
createdAt: json['created_at']?.toString() ?? '',
|
||||||
);
|
);
|
||||||
@@ -149,6 +152,7 @@ class ErrorNotebookItem {
|
|||||||
errorCategory: errorCategory,
|
errorCategory: errorCategory,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
remediationAttemptsCount: remediationAttemptsCount ?? this.remediationAttemptsCount,
|
remediationAttemptsCount: remediationAttemptsCount ?? this.remediationAttemptsCount,
|
||||||
|
retentionIsDue: retentionIsDue,
|
||||||
masteredAt: masteredAt ?? this.masteredAt,
|
masteredAt: masteredAt ?? this.masteredAt,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
@@ -158,16 +162,12 @@ class ErrorNotebookItem {
|
|||||||
class RemedialQuestion {
|
class RemedialQuestion {
|
||||||
final int id;
|
final int id;
|
||||||
final String question;
|
final String question;
|
||||||
final List<String> options;
|
final List<RemedialOption> options;
|
||||||
final int correctIndex;
|
|
||||||
final String explanation;
|
|
||||||
|
|
||||||
RemedialQuestion({
|
RemedialQuestion({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.question,
|
required this.question,
|
||||||
required this.options,
|
required this.options,
|
||||||
required this.correctIndex,
|
|
||||||
required this.explanation,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory RemedialQuestion.fromJson(Map<String, dynamic> json) {
|
factory RemedialQuestion.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -175,9 +175,17 @@ class RemedialQuestion {
|
|||||||
return RemedialQuestion(
|
return RemedialQuestion(
|
||||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
question: json['question']?.toString() ?? '',
|
question: json['question']?.toString() ?? '',
|
||||||
options: rawOptions.map((e) => e.toString()).toList(),
|
options: rawOptions.map((e) => RemedialOption.fromJson(Map<String, dynamic>.from(e as Map))).toList(),
|
||||||
correctIndex: (json['correct_index'] as num?)?.toInt() ?? 0,
|
|
||||||
explanation: json['explanation']?.toString() ?? '',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RemedialOption {
|
||||||
|
final int id;
|
||||||
|
final String text;
|
||||||
|
RemedialOption({required this.id, required this.text});
|
||||||
|
factory RemedialOption.fromJson(Map<String, dynamic> json) => RemedialOption(
|
||||||
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
|
text: json['text']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ class CurriculumLessonItemModel {
|
|||||||
final List<String> outcomes;
|
final List<String> outcomes;
|
||||||
final String? markdownFilePath;
|
final String? markdownFilePath;
|
||||||
final String? curriculumLessonId;
|
final String? curriculumLessonId;
|
||||||
|
final String? curriculumVersion;
|
||||||
|
final String? primaryLessonAssetId;
|
||||||
final int durationSeconds;
|
final int durationSeconds;
|
||||||
final int checkpointsCount;
|
final int checkpointsCount;
|
||||||
final bool isCompleted;
|
final bool isCompleted;
|
||||||
@@ -220,6 +222,8 @@ class CurriculumLessonItemModel {
|
|||||||
this.outcomes = const [],
|
this.outcomes = const [],
|
||||||
this.markdownFilePath,
|
this.markdownFilePath,
|
||||||
this.curriculumLessonId,
|
this.curriculumLessonId,
|
||||||
|
this.curriculumVersion,
|
||||||
|
this.primaryLessonAssetId,
|
||||||
this.durationSeconds = 0,
|
this.durationSeconds = 0,
|
||||||
this.checkpointsCount = 0,
|
this.checkpointsCount = 0,
|
||||||
this.isCompleted = false,
|
this.isCompleted = false,
|
||||||
@@ -254,6 +258,8 @@ class CurriculumLessonItemModel {
|
|||||||
outcomes: outs,
|
outcomes: outs,
|
||||||
markdownFilePath: filePath,
|
markdownFilePath: filePath,
|
||||||
curriculumLessonId: json['curriculum_lesson_id']?.toString(),
|
curriculumLessonId: json['curriculum_lesson_id']?.toString(),
|
||||||
|
curriculumVersion: json['curriculum_version']?.toString(),
|
||||||
|
primaryLessonAssetId: json['primary_lesson_asset_id']?.toString(),
|
||||||
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
|
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||||
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0,
|
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0,
|
||||||
isCompleted: (json['is_completed'] as bool?) ?? false,
|
isCompleted: (json['is_completed'] as bool?) ?? false,
|
||||||
|
|||||||
@@ -28,19 +28,53 @@ class GradeLevelModel {
|
|||||||
GradeLevelModel(key: 'grade_9', name: 'الصف التاسع الأساسي', number: 9),
|
GradeLevelModel(key: 'grade_9', name: 'الصف التاسع الأساسي', number: 9),
|
||||||
GradeLevelModel(key: 'grade_10', name: 'الصف العاشر الأساسي', number: 10),
|
GradeLevelModel(key: 'grade_10', name: 'الصف العاشر الأساسي', number: 10),
|
||||||
GradeLevelModel(key: 'grade_11', name: 'الأول ثانوي', number: 11),
|
GradeLevelModel(key: 'grade_11', name: 'الأول ثانوي', number: 11),
|
||||||
GradeLevelModel(key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12),
|
GradeLevelModel(
|
||||||
|
key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12),
|
||||||
];
|
];
|
||||||
|
|
||||||
static String normalizeGradeKey(String raw) {
|
static String normalizeGradeKey(String raw) {
|
||||||
final clean = raw.toLowerCase().trim();
|
final clean = raw.toLowerCase().trim();
|
||||||
if (clean.contains('عاشر') || clean.contains('grade_10') || clean.contains('grade 10') || clean.contains('grade10') || clean == '10') return 'grade_10';
|
if (clean.contains('عاشر') ||
|
||||||
if (clean.contains('تاسع') || clean.contains('grade_9') || clean.contains('grade 9') || clean.contains('grade9') || clean == '9') return 'grade_9';
|
clean.contains('grade_10') ||
|
||||||
if (clean.contains('ثامن') || clean.contains('grade_8') || clean.contains('grade 8') || clean.contains('grade8') || clean == '8') return 'grade_8';
|
clean.contains('grade 10') ||
|
||||||
if (clean.contains('سابع') || clean.contains('grade_7') || clean.contains('grade 7') || clean.contains('grade7') || clean == '7') return 'grade_7';
|
clean.contains('grade10') ||
|
||||||
if (clean.contains('سادس') || clean.contains('grade_6') || clean.contains('grade 6') || clean.contains('grade6') || clean == '6') return 'grade_6';
|
clean == '10') return 'grade_10';
|
||||||
if (clean.contains('خامس') || clean.contains('grade_5') || clean.contains('grade 5') || clean.contains('grade5') || clean == '5') return 'grade_5';
|
if (clean.contains('تاسع') ||
|
||||||
if (clean.contains('حادي') || clean.contains('أول ثانوي') || clean.contains('grade_11') || clean.contains('grade 11') || clean == '11') return 'grade_11';
|
clean.contains('grade_9') ||
|
||||||
if (clean.contains('ثاني عشر') || clean.contains('توجيهي') || clean.contains('tawjihi') || clean.contains('grade_12') || clean.contains('grade 12') || clean == '12') return 'tawjihi_2008';
|
clean.contains('grade 9') ||
|
||||||
|
clean.contains('grade9') ||
|
||||||
|
clean == '9') return 'grade_9';
|
||||||
|
if (clean.contains('ثامن') ||
|
||||||
|
clean.contains('grade_8') ||
|
||||||
|
clean.contains('grade 8') ||
|
||||||
|
clean.contains('grade8') ||
|
||||||
|
clean == '8') return 'grade_8';
|
||||||
|
if (clean.contains('سابع') ||
|
||||||
|
clean.contains('grade_7') ||
|
||||||
|
clean.contains('grade 7') ||
|
||||||
|
clean.contains('grade7') ||
|
||||||
|
clean == '7') return 'grade_7';
|
||||||
|
if (clean.contains('سادس') ||
|
||||||
|
clean.contains('grade_6') ||
|
||||||
|
clean.contains('grade 6') ||
|
||||||
|
clean.contains('grade6') ||
|
||||||
|
clean == '6') return 'grade_6';
|
||||||
|
if (clean.contains('خامس') ||
|
||||||
|
clean.contains('grade_5') ||
|
||||||
|
clean.contains('grade 5') ||
|
||||||
|
clean.contains('grade5') ||
|
||||||
|
clean == '5') return 'grade_5';
|
||||||
|
if (clean.contains('حادي') ||
|
||||||
|
clean.contains('أول ثانوي') ||
|
||||||
|
clean.contains('grade_11') ||
|
||||||
|
clean.contains('grade 11') ||
|
||||||
|
clean == '11') return 'grade_11';
|
||||||
|
if (clean.contains('ثاني عشر') ||
|
||||||
|
clean.contains('توجيهي') ||
|
||||||
|
clean.contains('tawjihi') ||
|
||||||
|
clean.contains('grade_12') ||
|
||||||
|
clean.contains('grade 12') ||
|
||||||
|
clean == '12') return 'tawjihi_2008';
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +88,7 @@ class GradeLevelModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 100% Live Dynamic Curriculum Repository (Zero Hardcoded Data)
|
/// Reads only the server-published curriculum catalogue.
|
||||||
/// Dynamically fetches all grades (الصف الأول إلى التوجيهي) and their subjects from Live API.
|
|
||||||
class CurriculumRepository {
|
class CurriculumRepository {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
|
||||||
@@ -63,11 +96,12 @@ class CurriculumRepository {
|
|||||||
|
|
||||||
/// Fetch full curriculum tree from server
|
/// Fetch full curriculum tree from server
|
||||||
Future<Map<String, dynamic>> getRawCurriculumTree() async {
|
Future<Map<String, dynamic>> getRawCurriculumTree() async {
|
||||||
final res = await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false);
|
final res =
|
||||||
if (res is Map && res['data'] != null && res['data'] is Map) {
|
await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false);
|
||||||
|
if (res is Map && res['status'] == 'success' && res['data'] is Map) {
|
||||||
return Map<String, dynamic>.from(res['data']);
|
return Map<String, dynamic>.from(res['data']);
|
||||||
}
|
}
|
||||||
return {};
|
throw ApiException('استجابة فهرس الدروس المنشورة غير صالحة.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch available grades from Live Server Manifest
|
/// Fetch available grades from Live Server Manifest
|
||||||
@@ -78,22 +112,28 @@ class CurriculumRepository {
|
|||||||
for (var grade in GradeLevelModel.allK12Grades) {
|
for (var grade in GradeLevelModel.allK12Grades) {
|
||||||
final norm = GradeLevelModel.normalizeGradeKey(grade.key);
|
final norm = GradeLevelModel.normalizeGradeKey(grade.key);
|
||||||
final shortKey = norm.replaceAll('grade_', '');
|
final shortKey = norm.replaceAll('grade_', '');
|
||||||
if (tree.containsKey(grade.key) || tree.containsKey(norm) || tree.containsKey(shortKey)) {
|
if (tree.containsKey(grade.key) ||
|
||||||
|
tree.containsKey(norm) ||
|
||||||
|
tree.containsKey(shortKey)) {
|
||||||
available.add(grade);
|
available.add(grade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return available.isNotEmpty ? available : GradeLevelModel.allK12Grades;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch subjects for a specific grade level dynamically from Live Backend
|
/// Fetch subjects for a specific grade level dynamically from Live Backend
|
||||||
Future<List<SubjectModel>> getSubjects({String gradeLevel = 'grade_10', String stream = 'scientific'}) async {
|
Future<List<SubjectModel>> getSubjects(
|
||||||
|
{String gradeLevel = 'grade_10', String stream = 'scientific'}) async {
|
||||||
final normGrade = GradeLevelModel.normalizeGradeKey(gradeLevel);
|
final normGrade = GradeLevelModel.normalizeGradeKey(gradeLevel);
|
||||||
AppLogger.log('Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...', tag: 'CURRICULUM_REPO');
|
AppLogger.log(
|
||||||
|
'Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...',
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
|
|
||||||
final tree = await getRawCurriculumTree();
|
final tree = await getRawCurriculumTree();
|
||||||
if (tree.isEmpty) {
|
if (tree.isEmpty) {
|
||||||
AppLogger.log('No curriculum data returned from server.', tag: 'CURRICULUM_REPO');
|
AppLogger.log('No curriculum data returned from server.',
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +159,8 @@ class CurriculumRepository {
|
|||||||
}
|
}
|
||||||
if (entry.value is Map && entry.value['name'] != null) {
|
if (entry.value is Map && entry.value['name'] != null) {
|
||||||
final name = entry.value['name'].toString();
|
final name = entry.value['name'].toString();
|
||||||
if (GradeLevelModel.normalizeGradeKey(name) == normGrade || name.contains(normGrade)) {
|
if (GradeLevelModel.normalizeGradeKey(name) == normGrade ||
|
||||||
|
name.contains(normGrade)) {
|
||||||
gradeData = Map<String, dynamic>.from(entry.value);
|
gradeData = Map<String, dynamic>.from(entry.value);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -128,8 +169,12 @@ class CurriculumRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gradeData == null || gradeData['subjects'] == null || gradeData['subjects'] is! Map) {
|
if (gradeData == null ||
|
||||||
AppLogger.log('No subjects found in grade $gradeLevel ($normGrade) on server.', tag: 'CURRICULUM_REPO');
|
gradeData['subjects'] == null ||
|
||||||
|
gradeData['subjects'] is! Map) {
|
||||||
|
AppLogger.log(
|
||||||
|
'No subjects found in grade $gradeLevel ($normGrade) on server.',
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,74 +188,130 @@ class CurriculumRepository {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
AppLogger.log('Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}', tag: 'CURRICULUM_REPO');
|
AppLogger.log(
|
||||||
|
'Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}',
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
return liveSubjects;
|
return liveSubjects;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(String curriculumLessonId) async {
|
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(
|
||||||
AppLogger.event('FetchPublishedVideosRequested', details: {'curriculumLessonId': curriculumLessonId}, tag: 'CURRICULUM_REPO');
|
String curriculumLessonId) async {
|
||||||
final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos');
|
return (await getPublishedLessonVideosPage(curriculumLessonId)).items;
|
||||||
final data = res is Map ? res['data'] : null;
|
|
||||||
final raw = data is Map ? data['items'] : null;
|
|
||||||
if (raw is! List) return const [];
|
|
||||||
final list = raw.whereType<Map>().map((item) => PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList();
|
|
||||||
AppLogger.event('FetchPublishedVideosSuccess', details: {'count': list.length, 'teachers': list.map((v) => v.teacherName).toList()}, tag: 'CURRICULUM_REPO');
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LessonPlaybackData> getVideoVersionPlayback(String videoVersionId) async {
|
Future<PublishedLessonVideoPage> getPublishedLessonVideosPage(
|
||||||
AppLogger.event('FetchVideoPlaybackRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
|
String curriculumLessonId,
|
||||||
|
{int cursor = 0,
|
||||||
|
int limit = 20}) async {
|
||||||
|
AppLogger.event('FetchPublishedVideosRequested',
|
||||||
|
details: {'curriculumLessonId': curriculumLessonId},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
|
final res = await _api.get(
|
||||||
|
'/api/curriculum/lessons/$curriculumLessonId/videos',
|
||||||
|
queryParams: {'cursor': cursor, 'limit': limit});
|
||||||
|
final data = res is Map ? res['data'] : null;
|
||||||
|
final raw = data is Map ? data['items'] : null;
|
||||||
|
if (raw is! List || data is! Map)
|
||||||
|
throw ApiException('استجابة قائمة الحصص غير صالحة.');
|
||||||
|
final list = raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) =>
|
||||||
|
PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.where((item) => item.videoVersionId.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
AppLogger.event('FetchPublishedVideosSuccess',
|
||||||
|
details: {
|
||||||
|
'count': list.length,
|
||||||
|
'teachers': list.map((v) => v.teacherName).toList()
|
||||||
|
},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
|
return PublishedLessonVideoPage(
|
||||||
|
items: list,
|
||||||
|
availableCount: (data['available_count'] as num?)?.toInt() ?? list.length,
|
||||||
|
nextCursor: (data['next_cursor'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LessonPlaybackData> getVideoVersionPlayback(
|
||||||
|
String videoVersionId) async {
|
||||||
|
AppLogger.event('FetchVideoPlaybackRequested',
|
||||||
|
details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
|
||||||
final res = await _api.get('/api/video-versions/$videoVersionId/playback');
|
final res = await _api.get('/api/video-versions/$videoVersionId/playback');
|
||||||
if (res is Map && res['data'] is Map) {
|
if (res is Map && res['data'] is Map) {
|
||||||
final pb = LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
final pb =
|
||||||
AppLogger.event('FetchVideoPlaybackSuccess', details: {
|
LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
||||||
'videoVersionId': pb.videoVersionId,
|
AppLogger.event('FetchVideoPlaybackSuccess',
|
||||||
'videoUrl': pb.videoUrl,
|
details: {
|
||||||
'duration': pb.durationSeconds,
|
'videoVersionId': pb.videoVersionId,
|
||||||
}, tag: 'CURRICULUM_REPO');
|
'duration': pb.durationSeconds,
|
||||||
|
},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
return pb;
|
return pb;
|
||||||
}
|
}
|
||||||
throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
|
throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> startWatchSession(String videoVersionId) async {
|
Future<String> startWatchSession(String videoVersionId) async {
|
||||||
AppLogger.event('StartWatchSessionRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
|
AppLogger.event('StartWatchSessionRequested',
|
||||||
final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {});
|
details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
|
||||||
|
final res = await _api.post(
|
||||||
|
'/api/video-versions/$videoVersionId/watch-sessions',
|
||||||
|
body: const {});
|
||||||
final data = res is Map ? res['data'] : null;
|
final data = res is Map ? res['data'] : null;
|
||||||
final source = data is Map ? data : res;
|
final source = data is Map ? data : res;
|
||||||
final id = source is Map ? source['watch_session_id']?.toString() : null;
|
final id = source is Map ? source['watch_session_id']?.toString() : null;
|
||||||
if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
|
if (id == null || id.isEmpty)
|
||||||
AppLogger.event('StartWatchSessionSuccess', details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
|
throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
|
||||||
|
AppLogger.event('StartWatchSessionSuccess',
|
||||||
|
details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
Future<void> recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async {
|
|
||||||
AppLogger.event('RecordWatchEvent', details: {
|
Future<void> recordWatchEvent(String sessionId, int sequenceNo,
|
||||||
'sessionId': sessionId,
|
String eventType, int positionMs) async {
|
||||||
'seq': sequenceNo,
|
AppLogger.event('RecordWatchEvent',
|
||||||
'type': eventType,
|
details: {
|
||||||
'positionMs': positionMs,
|
'sessionId': sessionId,
|
||||||
}, tag: 'CURRICULUM_REPO');
|
'seq': sequenceNo,
|
||||||
await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs});
|
'type': eventType,
|
||||||
|
'positionMs': positionMs,
|
||||||
|
},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
|
await _api.post('/api/watch-sessions/$sessionId/events', body: {
|
||||||
|
'sequence_no': sequenceNo,
|
||||||
|
'event_type': eventType,
|
||||||
|
'position_ms': positionMs
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async {
|
Future<void> saveProgress(
|
||||||
AppLogger.event('SaveProgressRequested', details: {
|
{required int lessonId,
|
||||||
'lessonId': lessonId,
|
required int positionSeconds,
|
||||||
'position': positionSeconds,
|
required int watchedSeconds}) async {
|
||||||
'watched': watchedSeconds,
|
AppLogger.event('SaveProgressRequested',
|
||||||
}, tag: 'CURRICULUM_REPO');
|
details: {
|
||||||
|
'lessonId': lessonId,
|
||||||
|
'position': positionSeconds,
|
||||||
|
'watched': watchedSeconds,
|
||||||
|
},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
await _api.post('/api/student/lessons/$lessonId/progress', body: {
|
await _api.post('/api/student/lessons/$lessonId/progress', body: {
|
||||||
'position_seconds': positionSeconds,
|
'position_seconds': positionSeconds,
|
||||||
'watched_seconds': watchedSeconds,
|
'watched_seconds': watchedSeconds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submitCheckpoint({required int examId, required int questionId, required int optionId}) async {
|
Future<void> submitCheckpoint(
|
||||||
AppLogger.event('SubmitCheckpointRequested', details: {
|
{required int examId,
|
||||||
'examId': examId,
|
required int questionId,
|
||||||
'questionId': questionId,
|
required int optionId}) async {
|
||||||
'optionId': optionId,
|
AppLogger.event('SubmitCheckpointRequested',
|
||||||
}, tag: 'CURRICULUM_REPO');
|
details: {
|
||||||
|
'examId': examId,
|
||||||
|
'questionId': questionId,
|
||||||
|
'optionId': optionId,
|
||||||
|
},
|
||||||
|
tag: 'CURRICULUM_REPO');
|
||||||
await _api.post('/api/exams/$examId/submit', body: {
|
await _api.post('/api/exams/$examId/submit', body: {
|
||||||
'answers': [
|
'answers': [
|
||||||
{'question_id': questionId, 'selected_option_id': optionId}
|
{'question_id': questionId, 'selected_option_id': optionId}
|
||||||
@@ -226,8 +327,36 @@ class PublishedLessonVideoModel {
|
|||||||
final double rating;
|
final double rating;
|
||||||
final int ratingCount;
|
final int ratingCount;
|
||||||
final bool isNew;
|
final bool isNew;
|
||||||
const PublishedLessonVideoModel({required this.videoVersionId, required this.submissionId, required this.teacherName, required this.rating, required this.ratingCount, required this.isNew});
|
final int durationSeconds;
|
||||||
factory PublishedLessonVideoModel.fromJson(Map<String, dynamic> json) => PublishedLessonVideoModel(
|
final DateTime? publishedAt;
|
||||||
videoVersionId: json['video_version_id']?.toString() ?? '', submissionId: json['submission_id']?.toString() ?? '', teacherName: json['teacher_name']?.toString() ?? 'معلم معتمد', rating: (json['rating'] as num?)?.toDouble() ?? 0, ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, isNew: json['is_new'] == true,
|
const PublishedLessonVideoModel(
|
||||||
);
|
{required this.videoVersionId,
|
||||||
|
required this.submissionId,
|
||||||
|
required this.teacherName,
|
||||||
|
required this.rating,
|
||||||
|
required this.ratingCount,
|
||||||
|
required this.isNew,
|
||||||
|
required this.durationSeconds,
|
||||||
|
this.publishedAt});
|
||||||
|
factory PublishedLessonVideoModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
PublishedLessonVideoModel(
|
||||||
|
videoVersionId: json['video_version_id']?.toString() ?? '',
|
||||||
|
submissionId: json['submission_id']?.toString() ?? '',
|
||||||
|
teacherName: json['teacher_name']?.toString() ?? '',
|
||||||
|
rating: (json['rating'] as num?)?.toDouble() ?? 0,
|
||||||
|
ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0,
|
||||||
|
isNew: json['is_new'] == true,
|
||||||
|
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||||
|
publishedAt: DateTime.tryParse(json['published_at']?.toString() ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PublishedLessonVideoPage {
|
||||||
|
final List<PublishedLessonVideoModel> items;
|
||||||
|
final int availableCount;
|
||||||
|
final int? nextCursor;
|
||||||
|
const PublishedLessonVideoPage(
|
||||||
|
{required this.items,
|
||||||
|
required this.availableCount,
|
||||||
|
required this.nextCursor});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,18 +23,7 @@ class ErrorNotebookRepository {
|
|||||||
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
||||||
'items': items,
|
'items': items,
|
||||||
};
|
};
|
||||||
} catch (_) {
|
} catch (_) { rethrow; }
|
||||||
return {
|
|
||||||
'summary': ErrorNotebookSummary(
|
|
||||||
totalErrors: 0,
|
|
||||||
masteredCount: 0,
|
|
||||||
pendingCount: 0,
|
|
||||||
masteryPercentage: 100.0,
|
|
||||||
bySubject: {},
|
|
||||||
),
|
|
||||||
'items': <ErrorNotebookItem>[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getChildErrorNotebook(int studentId) async {
|
Future<Map<String, dynamic>> getChildErrorNotebook(int studentId) async {
|
||||||
@@ -48,18 +37,7 @@ class ErrorNotebookRepository {
|
|||||||
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
||||||
'items': items,
|
'items': items,
|
||||||
};
|
};
|
||||||
} catch (_) {
|
} catch (_) { rethrow; }
|
||||||
return {
|
|
||||||
'summary': ErrorNotebookSummary(
|
|
||||||
totalErrors: 0,
|
|
||||||
masteredCount: 0,
|
|
||||||
pendingCount: 0,
|
|
||||||
masteryPercentage: 100.0,
|
|
||||||
bySubject: {},
|
|
||||||
),
|
|
||||||
'items': <ErrorNotebookItem>[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> logError({
|
Future<String?> logError({
|
||||||
@@ -99,30 +77,33 @@ class ErrorNotebookRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemedialQuestion>> getRemediationQuiz({required String errorUuid, required String topicName}) async {
|
Future<Map<String, dynamic>> getRemediationQuiz({required String errorUuid}) async {
|
||||||
try {
|
try {
|
||||||
final decoded = await _api.get(
|
final decoded = await _api.post(
|
||||||
'/api/student/error-notebook/remediation-quiz',
|
'/api/student/error-notebook/remediation-quiz',
|
||||||
queryParams: {'error_uuid': errorUuid, 'topic_name': topicName},
|
body: {'error_uuid': errorUuid},
|
||||||
);
|
);
|
||||||
final data = Map<String, dynamic>.from(decoded['data'] as Map? ?? const {});
|
final data = Map<String, dynamic>.from(decoded['data'] as Map? ?? const {});
|
||||||
return (data['questions'] as List? ?? const [])
|
return {
|
||||||
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
|
'attempt_uuid': data['attempt_uuid']?.toString() ?? '',
|
||||||
.toList();
|
'questions': (data['questions'] as List? ?? const [])
|
||||||
|
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [];
|
return {'attempt_uuid': '', 'questions': <RemedialQuestion>[]};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> resolveError(String errorUuid) async {
|
Future<Map<String, dynamic>?> resolveError(String errorUuid, String attemptUuid, List<int> answers) async {
|
||||||
try {
|
try {
|
||||||
final decoded = await _api.post(
|
final decoded = await _api.post(
|
||||||
'/api/student/error-notebook/resolve',
|
'/api/student/error-notebook/resolve',
|
||||||
body: {'error_uuid': errorUuid},
|
body: {'error_uuid': errorUuid, 'attempt_uuid': attemptUuid, 'answers': answers},
|
||||||
);
|
);
|
||||||
return decoded is Map && decoded['status'] == 'success';
|
return decoded is Map && decoded['status'] == 'success' ? Map<String, dynamic>.from(decoded) : null;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../data/repositories/error_notebook_repository.dart';
|
|||||||
abstract class VideoPlaybackState {}
|
abstract class VideoPlaybackState {}
|
||||||
|
|
||||||
class VideoPlaybackInitial extends VideoPlaybackState {}
|
class VideoPlaybackInitial extends VideoPlaybackState {}
|
||||||
|
|
||||||
class VideoPlaybackLoading extends VideoPlaybackState {}
|
class VideoPlaybackLoading extends VideoPlaybackState {}
|
||||||
|
|
||||||
class VideoPlaybackReady extends VideoPlaybackState {
|
class VideoPlaybackReady extends VideoPlaybackState {
|
||||||
@@ -53,9 +54,12 @@ class VideoPlaybackReady extends VideoPlaybackState {
|
|||||||
playbackData: playbackData ?? this.playbackData,
|
playbackData: playbackData ?? this.playbackData,
|
||||||
lessonItem: lessonItem ?? this.lessonItem,
|
lessonItem: lessonItem ?? this.lessonItem,
|
||||||
subject: subject ?? this.subject,
|
subject: subject ?? this.subject,
|
||||||
currentPositionSeconds: currentPositionSeconds ?? this.currentPositionSeconds,
|
currentPositionSeconds:
|
||||||
|
currentPositionSeconds ?? this.currentPositionSeconds,
|
||||||
isPlaying: isPlaying ?? this.isPlaying,
|
isPlaying: isPlaying ?? this.isPlaying,
|
||||||
activeCheckpoint: clearActiveCheckpoint ? null : (activeCheckpoint ?? this.activeCheckpoint),
|
activeCheckpoint: clearActiveCheckpoint
|
||||||
|
? null
|
||||||
|
: (activeCheckpoint ?? this.activeCheckpoint),
|
||||||
isCheckpointPassed: isCheckpointPassed ?? this.isCheckpointPassed,
|
isCheckpointPassed: isCheckpointPassed ?? this.isCheckpointPassed,
|
||||||
remediationNotice: remediationNotice,
|
remediationNotice: remediationNotice,
|
||||||
readinessBonusAdded: readinessBonusAdded ?? this.readinessBonusAdded,
|
readinessBonusAdded: readinessBonusAdded ?? this.readinessBonusAdded,
|
||||||
@@ -80,8 +84,10 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
: _repo = repo ?? CurriculumRepository(),
|
: _repo = repo ?? CurriculumRepository(),
|
||||||
super(VideoPlaybackInitial());
|
super(VideoPlaybackInitial());
|
||||||
|
|
||||||
String _getLessonStorageKey(CurriculumLessonItemModel? lesson, {String? fallbackId}) {
|
String _getLessonStorageKey(CurriculumLessonItemModel? lesson,
|
||||||
if (lesson?.markdownFilePath != null && lesson!.markdownFilePath!.isNotEmpty) {
|
{String? fallbackId}) {
|
||||||
|
if (lesson?.markdownFilePath != null &&
|
||||||
|
lesson!.markdownFilePath!.isNotEmpty) {
|
||||||
return lesson.markdownFilePath!.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
return lesson.markdownFilePath!.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
||||||
}
|
}
|
||||||
if (lesson?.id != null && lesson!.id.isNotEmpty) {
|
if (lesson?.id != null && lesson!.id.isNotEmpty) {
|
||||||
@@ -90,14 +96,17 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
return fallbackId ?? 'lesson_default';
|
return fallbackId ?? 'lesson_default';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async {
|
Future<void> loadLesson(CurriculumLessonItemModel lesson,
|
||||||
AppLogger.event('LoadLessonPlaybackRequested', details: {
|
{SubjectModel? subject, String? selectedVideoVersionId}) async {
|
||||||
'lessonTitle': lesson.title,
|
AppLogger.event('LoadLessonPlaybackRequested',
|
||||||
'lessonId': lesson.id,
|
details: {
|
||||||
'curriculumLessonId': lesson.curriculumLessonId,
|
'lessonTitle': lesson.title,
|
||||||
'selectedVersionId': selectedVideoVersionId,
|
'lessonId': lesson.id,
|
||||||
'hasVideo': lesson.hasVideo,
|
'curriculumLessonId': lesson.curriculumLessonId,
|
||||||
}, tag: 'VIDEO_CUBIT');
|
'selectedVersionId': selectedVideoVersionId,
|
||||||
|
'hasVideo': lesson.hasVideo,
|
||||||
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
_lastObservedPosition = -1;
|
_lastObservedPosition = -1;
|
||||||
emit(VideoPlaybackLoading());
|
emit(VideoPlaybackLoading());
|
||||||
final storageKey = _getLessonStorageKey(lesson);
|
final storageKey = _getLessonStorageKey(lesson);
|
||||||
@@ -108,32 +117,38 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
|
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
|
||||||
}
|
}
|
||||||
final published = await _repo.getPublishedLessonVideos(versionId);
|
final published = await _repo.getPublishedLessonVideos(versionId);
|
||||||
AppLogger.event('PublishedVideosLoaded', details: {
|
AppLogger.event('PublishedVideosLoaded',
|
||||||
'count': published.length,
|
details: {
|
||||||
'teachers': published.map((p) => '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})').toList(),
|
'count': published.length,
|
||||||
}, tag: 'VIDEO_CUBIT');
|
'teachers': published
|
||||||
if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
|
.map((p) =>
|
||||||
|
'${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})')
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
if (published.isEmpty)
|
||||||
|
throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
|
||||||
|
|
||||||
final targetVersionId = selectedVideoVersionId ?? published.first.videoVersionId;
|
final targetVersionId =
|
||||||
|
selectedVideoVersionId ?? published.first.videoVersionId;
|
||||||
var playback = await _repo.getVideoVersionPlayback(targetVersionId);
|
var playback = await _repo.getVideoVersionPlayback(targetVersionId);
|
||||||
AppLogger.event('PlaybackDataLoaded', details: {
|
AppLogger.event('PlaybackDataLoaded',
|
||||||
'versionId': playback.videoVersionId,
|
details: {
|
||||||
'videoUrl': playback.videoUrl,
|
'versionId': playback.videoVersionId,
|
||||||
'durationSeconds': playback.durationSeconds,
|
'durationSeconds': playback.durationSeconds,
|
||||||
'checkpointsCount': playback.checkpoints.length,
|
'checkpointsCount': playback.checkpoints.length,
|
||||||
}, tag: 'VIDEO_CUBIT');
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
|
||||||
if (playback.videoUrl.isEmpty) {
|
if (playback.videoUrl.isEmpty) {
|
||||||
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
|
// A watch session represents actual playback. The screen starts it only after
|
||||||
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) {
|
// the exact published video URL initializes successfully.
|
||||||
try {
|
_watchSessionId = null;
|
||||||
_watchSessionId = await _repo.startWatchSession(playback.videoVersionId!);
|
_watchSequence = 1;
|
||||||
AppLogger.event('WatchSessionStarted', details: {'sessionId': _watchSessionId}, tag: 'VIDEO_CUBIT');
|
_lastWatchEventPosition = 0;
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved resume position strictly per video
|
// Load saved resume position strictly per video
|
||||||
int resumePos = playback.lastPositionSeconds ?? 0;
|
int resumePos = playback.lastPositionSeconds ?? 0;
|
||||||
@@ -142,14 +157,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
|
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
|
||||||
if (localPos > resumePos) resumePos = localPos;
|
if (localPos > resumePos) resumePos = localPos;
|
||||||
final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
|
final savedPassed =
|
||||||
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
|
prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
|
||||||
|
passedIds = savedPassed
|
||||||
|
.map((s) => int.tryParse(s) ?? 0)
|
||||||
|
.where((id) => id > 0)
|
||||||
|
.toSet();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
AppLogger.event('VideoPlaybackStateEmitted', details: {
|
AppLogger.event('VideoPlaybackStateEmitted',
|
||||||
'resumePosition': resumePos,
|
details: {
|
||||||
'passedCheckpointsCount': passedIds.length,
|
'resumePosition': resumePos,
|
||||||
}, tag: 'VIDEO_CUBIT');
|
'passedCheckpointsCount': passedIds.length,
|
||||||
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
|
||||||
emit(VideoPlaybackReady(
|
emit(VideoPlaybackReady(
|
||||||
playbackData: playback,
|
playbackData: playback,
|
||||||
@@ -157,26 +178,33 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
subject: subject,
|
subject: subject,
|
||||||
currentPositionSeconds: resumePos,
|
currentPositionSeconds: resumePos,
|
||||||
passedCheckpointIds: passedIds,
|
passedCheckpointIds: passedIds,
|
||||||
isPlaying: true,
|
// Playback becomes active only after the screen initializes the actual stream.
|
||||||
|
isPlaying: false,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT');
|
AppLogger.error('Playback API unavailable (${lesson.title})',
|
||||||
|
error: e, tag: 'VIDEO_CUBIT');
|
||||||
emit(VideoPlaybackError(e.toString()));
|
emit(VideoPlaybackError(e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updatePosition(int seconds) {
|
void updatePosition(int seconds) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
if (currentState is VideoPlaybackReady &&
|
||||||
|
currentState.activeCheckpoint == null) {
|
||||||
// Check if natural forward playback reaches a Socratic checkpoint
|
// Check if natural forward playback reaches a Socratic checkpoint
|
||||||
for (var cp in currentState.playbackData.checkpoints) {
|
for (var cp in currentState.playbackData.checkpoints) {
|
||||||
final hitCheckpoint = !currentState.passedCheckpointIds.contains(cp.id) &&
|
final hitCheckpoint =
|
||||||
_lastObservedPosition < cp.timestampSeconds &&
|
!currentState.passedCheckpointIds.contains(cp.id) &&
|
||||||
seconds >= cp.timestampSeconds &&
|
_lastObservedPosition < cp.timestampSeconds &&
|
||||||
(seconds - _lastObservedPosition).abs() <= 3; // Natural sequential playback
|
seconds >= cp.timestampSeconds &&
|
||||||
|
(seconds - _lastObservedPosition).abs() <=
|
||||||
|
3; // Natural sequential playback
|
||||||
|
|
||||||
if (hitCheckpoint) {
|
if (hitCheckpoint) {
|
||||||
AppLogger.log('🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', tag: 'SOCRATIC_ENGINE');
|
AppLogger.log(
|
||||||
|
'🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})',
|
||||||
|
tag: 'SOCRATIC_ENGINE');
|
||||||
_lastObservedPosition = cp.timestampSeconds;
|
_lastObservedPosition = cp.timestampSeconds;
|
||||||
emit(currentState.copyWith(
|
emit(currentState.copyWith(
|
||||||
currentPositionSeconds: cp.timestampSeconds,
|
currentPositionSeconds: cp.timestampSeconds,
|
||||||
@@ -196,14 +224,43 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
final session = _watchSessionId;
|
final session = _watchSessionId;
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
_watchSessionId = null;
|
_watchSessionId = null;
|
||||||
try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {}
|
try {
|
||||||
|
await _repo.recordWatchEvent(session, ++_watchSequence, 'end',
|
||||||
|
_lastObservedPosition.clamp(0, 1 << 31) * 1000);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startWatchSessionAfterPlaybackReady() async {
|
||||||
|
final currentState = state;
|
||||||
|
final versionId = currentState is VideoPlaybackReady
|
||||||
|
? currentState.playbackData.videoVersionId
|
||||||
|
: null;
|
||||||
|
if (_watchSessionId != null || versionId == null || versionId.isEmpty)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
_watchSequence = 1;
|
||||||
|
_lastWatchEventPosition = 0;
|
||||||
|
_watchSessionId = await _repo.startWatchSession(versionId);
|
||||||
|
AppLogger.event('WatchSessionStartedAfterVideoReady',
|
||||||
|
details: {
|
||||||
|
'sessionId': _watchSessionId,
|
||||||
|
'videoVersionId': versionId,
|
||||||
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.log(
|
||||||
|
'Watch session unavailable after playback initialization: $e',
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void togglePlayPause() {
|
void togglePlayPause() {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
if (currentState is VideoPlaybackReady &&
|
||||||
|
currentState.activeCheckpoint == null) {
|
||||||
final nextState = !currentState.isPlaying;
|
final nextState = !currentState.isPlaying;
|
||||||
AppLogger.event('VideoPlayPauseToggled', details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT');
|
AppLogger.event('VideoPlayPauseToggled',
|
||||||
|
details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT');
|
||||||
emit(currentState.copyWith(isPlaying: nextState));
|
emit(currentState.copyWith(isPlaying: nextState));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +275,9 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) {
|
if (currentState is VideoPlaybackReady &&
|
||||||
|
!currentState.isPlaying &&
|
||||||
|
currentState.activeCheckpoint == null) {
|
||||||
AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT');
|
AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT');
|
||||||
emit(currentState.copyWith(isPlaying: true));
|
emit(currentState.copyWith(isPlaying: true));
|
||||||
}
|
}
|
||||||
@@ -230,23 +289,29 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
// Find the earliest unpassed checkpoint before or at this target
|
// Find the earliest unpassed checkpoint before or at this target
|
||||||
SocraticCheckpointModel? blockingCheckpoint;
|
SocraticCheckpointModel? blockingCheckpoint;
|
||||||
for (var cp in currentState.playbackData.checkpoints) {
|
for (var cp in currentState.playbackData.checkpoints) {
|
||||||
if (!currentState.passedCheckpointIds.contains(cp.id) && seconds > cp.timestampSeconds) {
|
if (!currentState.passedCheckpointIds.contains(cp.id) &&
|
||||||
if (blockingCheckpoint == null || cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
|
seconds > cp.timestampSeconds) {
|
||||||
|
if (blockingCheckpoint == null ||
|
||||||
|
cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
|
||||||
blockingCheckpoint = cp;
|
blockingCheckpoint = cp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint
|
// If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint
|
||||||
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
|
final actualSeek = blockingCheckpoint != null
|
||||||
|
? blockingCheckpoint.timestampSeconds
|
||||||
|
: seconds;
|
||||||
_lastObservedPosition = actualSeek;
|
_lastObservedPosition = actualSeek;
|
||||||
|
|
||||||
AppLogger.event('VideoSeekExecuted', details: {
|
AppLogger.event('VideoSeekExecuted',
|
||||||
'targetSeconds': seconds,
|
details: {
|
||||||
'actualSeek': actualSeek,
|
'targetSeconds': seconds,
|
||||||
'blockedByCheckpoint': blockingCheckpoint != null,
|
'actualSeek': actualSeek,
|
||||||
'checkpointQuestion': blockingCheckpoint?.questionText,
|
'blockedByCheckpoint': blockingCheckpoint != null,
|
||||||
}, tag: 'VIDEO_CUBIT');
|
'checkpointQuestion': blockingCheckpoint?.questionText,
|
||||||
|
},
|
||||||
|
tag: 'VIDEO_CUBIT');
|
||||||
|
|
||||||
emit(currentState.copyWith(
|
emit(currentState.copyWith(
|
||||||
currentPositionSeconds: actualSeek,
|
currentPositionSeconds: actualSeek,
|
||||||
@@ -256,11 +321,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async {
|
Future<void> saveProgress(
|
||||||
|
{required int positionSeconds, required int watchedSeconds}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is! VideoPlaybackReady) return;
|
if (currentState is! VideoPlaybackReady) return;
|
||||||
try {
|
try {
|
||||||
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
|
final storageKey = _getLessonStorageKey(currentState.lessonItem,
|
||||||
|
fallbackId: '${currentState.playbackData.lessonId}');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt('saved_video_pos_$storageKey', positionSeconds);
|
await prefs.setInt('saved_video_pos_$storageKey', positionSeconds);
|
||||||
if (currentState.passedCheckpointIds.isNotEmpty) {
|
if (currentState.passedCheckpointIds.isNotEmpty) {
|
||||||
@@ -270,11 +337,17 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (currentState.playbackData.lessonId > 0) {
|
if (currentState.playbackData.lessonId > 0) {
|
||||||
await _repo.saveProgress(lessonId: currentState.playbackData.lessonId, positionSeconds: positionSeconds, watchedSeconds: watchedSeconds);
|
await _repo.saveProgress(
|
||||||
|
lessonId: currentState.playbackData.lessonId,
|
||||||
|
positionSeconds: positionSeconds,
|
||||||
|
watchedSeconds: watchedSeconds);
|
||||||
}
|
}
|
||||||
if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) {
|
if (_watchSessionId != null &&
|
||||||
_watchSequence++; _lastWatchEventPosition = positionSeconds;
|
positionSeconds - _lastWatchEventPosition >= 30) {
|
||||||
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000);
|
_watchSequence++;
|
||||||
|
_lastWatchEventPosition = positionSeconds;
|
||||||
|
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence,
|
||||||
|
'heartbeat', positionSeconds * 1000);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
|
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||||||
@@ -284,24 +357,30 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
/// Submit Socratic Checkpoint Answer
|
/// Submit Socratic Checkpoint Answer
|
||||||
bool submitCheckpointAnswer(SocraticOptionModel selectedOption) {
|
bool submitCheckpointAnswer(SocraticOptionModel selectedOption) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint != null) {
|
if (currentState is VideoPlaybackReady &&
|
||||||
|
currentState.activeCheckpoint != null) {
|
||||||
final cp = currentState.activeCheckpoint!;
|
final cp = currentState.activeCheckpoint!;
|
||||||
|
|
||||||
if (selectedOption.isCorrect) {
|
if (selectedOption.isCorrect) {
|
||||||
// Correct Answer -> Reward readiness score (+0.5%) & resume video
|
// Correct Answer -> Reward readiness score (+0.5%) & resume video
|
||||||
AppLogger.event('SocraticAnswerCorrect', details: {
|
AppLogger.event('SocraticAnswerCorrect',
|
||||||
'checkpointId': cp.id,
|
details: {
|
||||||
'question': cp.questionText,
|
'checkpointId': cp.id,
|
||||||
'selected': selectedOption.text,
|
'question': cp.questionText,
|
||||||
'bonus': 0.5,
|
'selected': selectedOption.text,
|
||||||
}, tag: 'SOCRATIC_ENGINE');
|
'bonus': 0.5,
|
||||||
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
|
},
|
||||||
|
tag: 'SOCRATIC_ENGINE');
|
||||||
|
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)
|
||||||
|
..add(cp.id);
|
||||||
_lastObservedPosition = cp.timestampSeconds;
|
_lastObservedPosition = cp.timestampSeconds;
|
||||||
|
|
||||||
// Persist passed checkpoint for this specific video
|
// Persist passed checkpoint for this specific video
|
||||||
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
|
final storageKey = _getLessonStorageKey(currentState.lessonItem,
|
||||||
|
fallbackId: '${currentState.playbackData.lessonId}');
|
||||||
SharedPreferences.getInstance().then((prefs) {
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
prefs.setStringList('passed_checkpoints_$storageKey', updatedPassed.map((id) => id.toString()).toList());
|
prefs.setStringList('passed_checkpoints_$storageKey',
|
||||||
|
updatedPassed.map((id) => id.toString()).toList());
|
||||||
}).catchError((_) {});
|
}).catchError((_) {});
|
||||||
|
|
||||||
emit(currentState.copyWith(
|
emit(currentState.copyWith(
|
||||||
@@ -309,36 +388,47 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
readinessBonusAdded: currentState.readinessBonusAdded + 0.5,
|
readinessBonusAdded: currentState.readinessBonusAdded + 0.5,
|
||||||
passedCheckpointIds: updatedPassed,
|
passedCheckpointIds: updatedPassed,
|
||||||
remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
|
remediationNotice:
|
||||||
|
'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
|
||||||
));
|
));
|
||||||
if (cp.id > 0) {
|
if (cp.id > 0) {
|
||||||
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
_repo
|
||||||
|
.submitCheckpoint(
|
||||||
|
examId: cp.id,
|
||||||
|
questionId: cp.questionId,
|
||||||
|
optionId: selectedOption.id)
|
||||||
|
.catchError((e) {
|
||||||
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
|
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
|
||||||
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
|
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail)
|
||||||
|
.clamp(0, currentState.playbackData.durationSeconds);
|
||||||
_lastObservedPosition = rewindTo;
|
_lastObservedPosition = rewindTo;
|
||||||
AppLogger.event('SocraticAnswerIncorrect', details: {
|
AppLogger.event('SocraticAnswerIncorrect',
|
||||||
'checkpointId': cp.id,
|
details: {
|
||||||
'question': cp.questionText,
|
'checkpointId': cp.id,
|
||||||
'selected': selectedOption.text,
|
'question': cp.questionText,
|
||||||
'rewindSeconds': cp.rewindSecondsOnFail,
|
'selected': selectedOption.text,
|
||||||
'rewindTo': rewindTo,
|
'rewindSeconds': cp.rewindSecondsOnFail,
|
||||||
}, tag: 'SOCRATIC_ENGINE');
|
'rewindTo': rewindTo,
|
||||||
|
},
|
||||||
|
tag: 'SOCRATIC_ENGINE');
|
||||||
emit(currentState.copyWith(
|
emit(currentState.copyWith(
|
||||||
clearActiveCheckpoint: true,
|
clearActiveCheckpoint: true,
|
||||||
currentPositionSeconds: rewindTo,
|
currentPositionSeconds: rewindTo,
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
|
remediationNotice:
|
||||||
|
'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
|
||||||
));
|
));
|
||||||
|
|
||||||
// Auto-record gap to Smart Error Notebook
|
// Auto-record gap to Smart Error Notebook
|
||||||
final correctOpt = cp.options.firstWhere(
|
final correctOpt = cp.options.firstWhere(
|
||||||
(o) => o.isCorrect,
|
(o) => o.isCorrect,
|
||||||
orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false),
|
orElse: () =>
|
||||||
|
const SocraticOptionModel(id: 0, text: '', isCorrect: false),
|
||||||
);
|
);
|
||||||
ErrorNotebookRepository().logError(
|
ErrorNotebookRepository().logError(
|
||||||
subjectId: currentState.subject?.id ?? 'physics_10',
|
subjectId: currentState.subject?.id ?? 'physics_10',
|
||||||
@@ -350,12 +440,19 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
correctAnswer: correctOpt.text,
|
correctAnswer: correctOpt.text,
|
||||||
hint: cp.hint,
|
hint: cp.hint,
|
||||||
errorCategory: 'conceptual',
|
errorCategory: 'conceptual',
|
||||||
lessonId: currentState.playbackData.lessonId > 0 ? currentState.playbackData.lessonId : null,
|
lessonId: currentState.playbackData.lessonId > 0
|
||||||
|
? currentState.playbackData.lessonId
|
||||||
|
: null,
|
||||||
options: cp.options.map((o) => o.text).toList(),
|
options: cp.options.map((o) => o.text).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cp.id > 0 && cp.questionId > 0) {
|
if (cp.id > 0 && cp.questionId > 0) {
|
||||||
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
_repo
|
||||||
|
.submitCheckpoint(
|
||||||
|
examId: cp.id,
|
||||||
|
questionId: cp.questionId,
|
||||||
|
optionId: selectedOption.id)
|
||||||
|
.catchError((e) {
|
||||||
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+698
-591
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -116,7 +116,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
Icon(CupertinoIcons.checkmark_seal_fill, color: AppColors.saqelCyan, size: 14),
|
Icon(CupertinoIcons.checkmark_seal_fill, color: AppColors.saqelCyan, size: 14),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'المنهاج الوزاري الأردني 🇯🇴',
|
'فهرس الدروس المنشورة',
|
||||||
style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
|
style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,7 +141,9 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
LuxuryButton(
|
LuxuryButton(
|
||||||
text: 'إعادة المحاولة',
|
text: 'إعادة المحاولة',
|
||||||
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(),
|
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(
|
||||||
|
gradeLevel: GradeLevelModel.normalizeGradeKey(widget.initialGrade ?? 'grade_10'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -162,7 +164,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'المواد المقررة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
|
'المواد المنشورة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
|
||||||
style: AppTypography.displayLarge.copyWith(color: Colors.white, fontSize: 20),
|
style: AppTypography.displayLarge.copyWith(color: Colors.white, fontSize: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -188,7 +190,8 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Official Academic Grade Level Banner (Strict Grade-Gate — الصف العاشر فقط)
|
// The catalogue lists reviewed publications; do not claim
|
||||||
|
// ministry certification without a separate source of proof.
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -212,12 +215,12 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد',
|
'${GradeLevelModel.getGradeName(state.selectedGrade)} — الدروس المنشورة',
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
const Text(
|
const Text(
|
||||||
'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور',
|
'تظهر هنا الدروس التي اكتملت مراجعتها ونُشرت على المنصة',
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -255,15 +258,17 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
|
|||||||
child: const Icon(CupertinoIcons.square_grid_2x2, size: 32, color: AppColors.saqelCyan),
|
child: const Icon(CupertinoIcons.square_grid_2x2, size: 32, color: AppColors.saqelCyan),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'لا توجد مواد مضافة بعد في السيرفر',
|
state.subjects.isEmpty ? 'لا توجد دروس منشورة لهذا الصف بعد' : 'لا توجد مواد تطابق البحث',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Text(
|
Text(
|
||||||
'يتم إدراج المواد تلقائياً بمجرد رفع وتحليل كتب المناهج في الخادم',
|
state.subjects.isEmpty
|
||||||
|
? 'يمكنك العودة لاحقاً للاطلاع على الحزم بعد نشرها.'
|
||||||
|
: 'جرّب اسم مادة مختلفاً أو امسح البحث.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
|
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,20 +7,26 @@ import '../../widgets/luxury_widgets.dart';
|
|||||||
|
|
||||||
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
|
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
|
||||||
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
|
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
|
||||||
class TeacherSelectionSheet extends StatelessWidget {
|
class TeacherSelectionSheet extends StatefulWidget {
|
||||||
final CurriculumLessonItemModel lesson;
|
final CurriculumLessonItemModel lesson;
|
||||||
final List<PublishedLessonVideoModel> videos;
|
final List<PublishedLessonVideoModel> videos;
|
||||||
|
final int? nextCursor;
|
||||||
|
final int availableCount;
|
||||||
|
|
||||||
const TeacherSelectionSheet({
|
const TeacherSelectionSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.lesson,
|
required this.lesson,
|
||||||
required this.videos,
|
required this.videos,
|
||||||
|
required this.nextCursor,
|
||||||
|
required this.availableCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<PublishedLessonVideoModel?> show(
|
static Future<PublishedLessonVideoModel?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required CurriculumLessonItemModel lesson,
|
required CurriculumLessonItemModel lesson,
|
||||||
required List<PublishedLessonVideoModel> videos,
|
required List<PublishedLessonVideoModel> videos,
|
||||||
|
required int? nextCursor,
|
||||||
|
required int availableCount,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<PublishedLessonVideoModel>(
|
return showModalBottomSheet<PublishedLessonVideoModel>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -29,20 +35,43 @@ class TeacherSelectionSheet extends StatelessWidget {
|
|||||||
builder: (sheetContext) => TeacherSelectionSheet(
|
builder: (sheetContext) => TeacherSelectionSheet(
|
||||||
lesson: lesson,
|
lesson: lesson,
|
||||||
videos: videos,
|
videos: videos,
|
||||||
|
nextCursor: nextCursor,
|
||||||
|
availableCount: availableCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<TeacherSelectionSheet> createState() => _TeacherSelectionSheetState();
|
||||||
// Sort server-authoritatively by rating desc, then rating count desc
|
}
|
||||||
final sortedVideos = List<PublishedLessonVideoModel>.from(videos)
|
|
||||||
..sort((a, b) {
|
|
||||||
final cmp = b.rating.compareTo(a.rating);
|
|
||||||
if (cmp != 0) return cmp;
|
|
||||||
return b.ratingCount.compareTo(a.ratingCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
class _TeacherSelectionSheetState extends State<TeacherSelectionSheet> {
|
||||||
|
late final List<PublishedLessonVideoModel> _videos = List.of(widget.videos);
|
||||||
|
late int? _nextCursor = widget.nextCursor;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _failed = false;
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
final cursor = _nextCursor;
|
||||||
|
final lessonId = widget.lesson.curriculumLessonId;
|
||||||
|
if (_loading || cursor == null || lessonId == null) return;
|
||||||
|
setState(() { _loading = true; _failed = false; });
|
||||||
|
try {
|
||||||
|
final page = await CurriculumRepository().getPublishedLessonVideosPage(lessonId, cursor: cursor, limit: 20);
|
||||||
|
if (!mounted) return;
|
||||||
|
final known = _videos.map((video) => video.videoVersionId).toSet();
|
||||||
|
setState(() {
|
||||||
|
_videos.addAll(page.items.where((video) => known.add(video.videoVersionId)));
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() { _loading = false; _failed = true; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||||
@@ -103,7 +132,7 @@ class TeacherSelectionSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
lesson.title,
|
widget.lesson.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -161,23 +190,21 @@ class TeacherSelectionSheet extends StatelessWidget {
|
|||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: sortedVideos.length,
|
itemCount: _videos.length + (_nextCursor != null ? 1 : 0),
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
final video = sortedVideos[idx];
|
if (idx == _videos.length) {
|
||||||
|
return Center(child: TextButton(
|
||||||
|
onPressed: _loading ? null : _loadMore,
|
||||||
|
child: Text(_loading ? 'جارٍ تحميل المزيد…' : _failed ? 'تعذر التحميل — أعد المحاولة' : 'عرض المزيد (${_videos.length} من ${widget.availableCount})'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final video = _videos[idx];
|
||||||
final isNew = video.ratingCount == 0;
|
final isNew = video.ratingCount == 0;
|
||||||
final initials = video.teacherName.isNotEmpty
|
final initials = video.teacherName.isNotEmpty
|
||||||
? video.teacherName.trim().characters.first
|
? video.teacherName.trim().characters.first
|
||||||
: 'م';
|
: 'م';
|
||||||
|
|
||||||
final approaches = [
|
|
||||||
'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي',
|
|
||||||
'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية',
|
|
||||||
'تدريبات مكثفة ونماذج امتحانات وزارية سابقة',
|
|
||||||
'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة',
|
|
||||||
];
|
|
||||||
final approach = approaches[idx % approaches.length];
|
|
||||||
|
|
||||||
return LuxuryCard(
|
return LuxuryCard(
|
||||||
borderColor: idx == 0
|
borderColor: idx == 0
|
||||||
? AppColors.saqelCyan.withAlpha(90)
|
? AppColors.saqelCyan.withAlpha(90)
|
||||||
@@ -344,8 +371,8 @@ class TeacherSelectionSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// Pedagogical approach badge
|
// Only metadata supplied by the published video.
|
||||||
Container(
|
if (video.durationSeconds > 0 || video.publishedAt != null) Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10, vertical: 5),
|
horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -356,14 +383,17 @@ class TeacherSelectionSheet extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(
|
||||||
CupertinoIcons.lightbulb_fill,
|
CupertinoIcons.time,
|
||||||
color: AppColors.guardianAmber,
|
color: AppColors.guardianAmber,
|
||||||
size: 12,
|
size: 12,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
approach,
|
[
|
||||||
|
if (video.durationSeconds > 0) '${(video.durationSeconds / 60).ceil()} دقيقة',
|
||||||
|
if (video.publishedAt != null) 'نُشرت ${video.publishedAt!.year}-${video.publishedAt!.month.toString().padLeft(2, '0')}-${video.publishedAt!.day.toString().padLeft(2, '0')}',
|
||||||
|
].join(' · '),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textSecondaryDark,
|
color: AppColors.textSecondaryDark,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant UnifiedHomeScreen oldWidget) {
|
void didUpdateWidget(covariant UnifiedHomeScreen oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.user.gradeLevel != widget.user.gradeLevel || oldWidget.user.stream != widget.user.stream) {
|
if (oldWidget.user.gradeLevel != widget.user.gradeLevel ||
|
||||||
|
oldWidget.user.stream != widget.user.stream) {
|
||||||
_refreshData();
|
_refreshData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,10 +51,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
void _refreshData() {
|
void _refreshData() {
|
||||||
if (_currentRole == 'student') {
|
if (_currentRole == 'student') {
|
||||||
context.read<StudentCubit>().fetchLessons(
|
context.read<StudentCubit>().fetchLessons(
|
||||||
gradeLevel: widget.user.gradeLevel,
|
gradeLevel: widget.user.gradeLevel,
|
||||||
stream: widget.user.stream,
|
stream: widget.user.stream,
|
||||||
initialReadiness: widget.user.readinessScore ?? 0.0,
|
initialReadiness: widget.user.readinessScore ?? 0.0,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
context.read<GuardianCubit>().fetchDashboard();
|
context.read<GuardianCubit>().fetchDashboard();
|
||||||
}
|
}
|
||||||
@@ -83,7 +84,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.darkSurface,
|
color: AppColors.darkSurface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
border: Border(top: BorderSide(color: AppColors.darkCardBorder, width: 1.5)),
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: AppColors.darkCardBorder, width: 1.5)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -109,7 +112,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
color: AppColors.saqelCyan.withAlpha(30),
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 24),
|
child: const Icon(CupertinoIcons.book_fill,
|
||||||
|
color: AppColors.saqelCyan, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
@@ -118,12 +122,17 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'تحديد وتعديل الصف الدراسي 🎓',
|
'تحديد وتعديل الصف الدراسي 🎓',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
SizedBox(height: 3),
|
SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'اختر صفك لفتح المناهج الوزارية والشروحات المعتمدة',
|
'اختر صفك لفتح المناهج الوزارية والشروحات المعتمدة',
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -132,9 +141,21 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
...[
|
...[
|
||||||
('grade_10', 'الصف العاشر الأساسي (Grade 10)', 'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'),
|
(
|
||||||
('grade_11', 'الصف الحادي عشر (الأول ثانوي)', 'المسار الأكاديمي للفرعين العلمي والأدبي'),
|
'grade_10',
|
||||||
('grade_12', 'الصف الثاني عشر (التوجيهي الوزاري)', 'منهاج الثانوية العامة التوجيهي الوزاري'),
|
'الصف العاشر الأساسي (Grade 10)',
|
||||||
|
'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'grade_11',
|
||||||
|
'الصف الحادي عشر (الأول ثانوي)',
|
||||||
|
'المسار الأكاديمي للفرعين العلمي والأدبي'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'grade_12',
|
||||||
|
'الصف الثاني عشر (التوجيهي الوزاري)',
|
||||||
|
'منهاج الثانوية العامة التوجيهي الوزاري'
|
||||||
|
),
|
||||||
].map((g) {
|
].map((g) {
|
||||||
final isSelected = tempGrade == g.$1;
|
final isSelected = tempGrade == g.$1;
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -143,44 +164,61 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
setModalState(() {
|
setModalState(() {
|
||||||
tempGrade = g.$1;
|
tempGrade = g.$1;
|
||||||
if (tempGrade == 'grade_10') tempStream = 'general';
|
if (tempGrade == 'grade_10')
|
||||||
|
tempStream = 'general';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? AppColors.saqelCyan.withAlpha(25) : AppColors.darkBackground,
|
color: isSelected
|
||||||
|
? AppColors.saqelCyan.withAlpha(25)
|
||||||
|
: AppColors.darkBackground,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? AppColors.saqelCyan : AppColors.darkCardBorder,
|
color: isSelected
|
||||||
|
? AppColors.saqelCyan
|
||||||
|
: AppColors.darkCardBorder,
|
||||||
width: isSelected ? 1.5 : 1.0,
|
width: isSelected ? 1.5 : 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isSelected ? CupertinoIcons.checkmark_circle_fill : CupertinoIcons.circle,
|
isSelected
|
||||||
color: isSelected ? AppColors.saqelCyan : AppColors.textMutedDark,
|
? CupertinoIcons.checkmark_circle_fill
|
||||||
|
: CupertinoIcons.circle,
|
||||||
|
color: isSelected
|
||||||
|
? AppColors.saqelCyan
|
||||||
|
: AppColors.textMutedDark,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
g.$2,
|
g.$2,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? Colors.white : AppColors.textSecondaryDark,
|
color: isSelected
|
||||||
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600,
|
? Colors.white
|
||||||
|
: AppColors.textSecondaryDark,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w800
|
||||||
|
: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
g.$3,
|
g.$3,
|
||||||
style: const TextStyle(color: AppColors.textMutedDark, fontSize: 11),
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMutedDark,
|
||||||
|
fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -196,7 +234,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
text: 'حفظ الصف وتحديث المسار 🚀',
|
text: 'حفظ الصف وتحديث المسار 🚀',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
await context.read<AuthCubit>().updateGrade(tempGrade, stream: tempStream);
|
await context
|
||||||
|
.read<AuthCubit>()
|
||||||
|
.updateGrade(tempGrade, stream: tempStream);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_refreshData();
|
_refreshData();
|
||||||
SaqelToast.showSuccess(
|
SaqelToast.showSuccess(
|
||||||
@@ -225,7 +265,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
body: Container(
|
body: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: RadialGradient(
|
gradient: RadialGradient(
|
||||||
center: isStudent ? const Alignment(0.8, -0.6) : const Alignment(-0.8, -0.6),
|
center: isStudent
|
||||||
|
? const Alignment(0.8, -0.6)
|
||||||
|
: const Alignment(-0.8, -0.6),
|
||||||
radius: 1.3,
|
radius: 1.3,
|
||||||
colors: isStudent
|
colors: isStudent
|
||||||
? const [Color(0xFF0A2540), AppColors.darkBackground]
|
? const [Color(0xFF0A2540), AppColors.darkBackground]
|
||||||
@@ -277,7 +319,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'ص',
|
'ص',
|
||||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: Colors.black),
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.black),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -294,17 +339,20 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _showGradeSelectionModal(context),
|
onTap: () => _showGradeSelectionModal(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.saqelCyan.withAlpha(30),
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
|
border: Border.all(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'),
|
GradeLevelModel.getGradeName(
|
||||||
|
widget.user.gradeLevel ?? 'grade_10'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.saqelCyan,
|
color: AppColors.saqelCyan,
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
@@ -312,7 +360,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const Icon(CupertinoIcons.chevron_down, color: AppColors.saqelCyan, size: 10),
|
const Icon(CupertinoIcons.chevron_down,
|
||||||
|
color: AppColors.saqelCyan, size: 10),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -320,7 +369,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
AppStrings.guardianPortal,
|
AppStrings.guardianPortal,
|
||||||
style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark),
|
style: AppTypography.labelSmall
|
||||||
|
.copyWith(color: AppColors.textMutedDark),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -336,8 +386,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildRolePill('الطالب', isStudent, () => _switchRole('student')),
|
_buildRolePill(
|
||||||
_buildRolePill('ولي الأمر', !isStudent, () => _switchRole('guardian')),
|
'الطالب', isStudent, () => _switchRole('student')),
|
||||||
|
_buildRolePill(
|
||||||
|
'ولي الأمر', !isStudent, () => _switchRole('guardian')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -345,7 +397,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
|
|
||||||
// Logout Icon
|
// Logout Icon
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(CupertinoIcons.power, color: AppColors.errorRed, size: 20),
|
icon: const Icon(CupertinoIcons.power,
|
||||||
|
color: AppColors.errorRed, size: 20),
|
||||||
onPressed: () => context.read<AuthCubit>().logout(),
|
onPressed: () => context.read<AuthCubit>().logout(),
|
||||||
tooltip: AppStrings.logout,
|
tooltip: AppStrings.logout,
|
||||||
),
|
),
|
||||||
@@ -388,9 +441,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
|
const Icon(CupertinoIcons.exclamationmark_triangle,
|
||||||
|
color: AppColors.errorRed, size: 40),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(state.message, style: const TextStyle(color: Colors.white)),
|
Text(state.message,
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _refreshData,
|
onPressed: _refreshData,
|
||||||
@@ -412,27 +467,37 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.saqelCyan.withAlpha(25),
|
color: AppColors.saqelCyan.withAlpha(25),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 1.5),
|
border: Border.all(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(80), width: 1.5),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 36),
|
child: const Icon(CupertinoIcons.book_fill,
|
||||||
|
color: AppColors.saqelCyan, size: 36),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
const Text(
|
const Text(
|
||||||
'المنهاج الوزاري المعتمد — صَقِل 🇯🇴',
|
'المنهاج الوزاري المعتمد — صَقِل 🇯🇴',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
'اختر من بين 16 مبحثاً دراسياً لبدء شروحات الفيديو التفاعلية، أوراق العمل، وبنك الأسئلة المعتمد',
|
'لا توجد حصص تشغيل منشورة لهذا الحساب حالياً. يمكنك تصفح الدروس والموارد التي اجتازت النشر من فهرس المنهاج.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.4),
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
LuxuryButton(
|
LuxuryButton(
|
||||||
text: 'تصفح المناهج والمواد 📚',
|
text: 'تصفح المناهج والمواد 📚',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SubjectsGridScreen(
|
||||||
|
initialGrade: widget.user.gradeLevel)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -450,7 +515,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SubjectsGridScreen(
|
||||||
|
initialGrade: widget.user.gradeLevel)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -463,7 +530,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(100), width: 1.5),
|
border: Border.all(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(100),
|
||||||
|
width: 1.5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.saqelCyan.withAlpha(30),
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
@@ -479,11 +548,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
colors: [AppColors.appleBlue, AppColors.saqelCyan],
|
colors: [
|
||||||
|
AppColors.appleBlue,
|
||||||
|
AppColors.saqelCyan
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.square_grid_2x2_fill, color: Colors.black, size: 24),
|
child: const Icon(CupertinoIcons.square_grid_2x2_fill,
|
||||||
|
color: Colors.black, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
@@ -491,18 +564,24 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'تصفح المناهج والمواد (16 مادة) 📚',
|
'تصفح المناهج والمواد 📚',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 15),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'الرياضيات، الفيزياء، الإنجليزي، الكيمياء، والمزيد',
|
'الرياضيات، الفيزياء، الإنجليزي، الكيمياء، والمزيد',
|
||||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(CupertinoIcons.chevron_back, color: AppColors.saqelCyan, size: 18),
|
const Icon(CupertinoIcons.chevron_back,
|
||||||
|
color: AppColors.saqelCyan, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -513,7 +592,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const VocationalTrainingScreen()),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const VocationalTrainingScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -526,7 +606,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: const Color(0xFF10B981).withAlpha(160), width: 1.5),
|
border: Border.all(
|
||||||
|
color: const Color(0xFF10B981).withAlpha(160),
|
||||||
|
width: 1.5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF10B981).withAlpha(35),
|
color: const Color(0xFF10B981).withAlpha(35),
|
||||||
@@ -548,7 +630,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.wrench_fill, color: Colors.black, size: 24),
|
child: const Icon(CupertinoIcons.wrench_fill,
|
||||||
|
color: Colors.black, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
@@ -557,17 +640,24 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'مؤسسة التدريب المهني (VTC Hub) ⚡',
|
'مؤسسة التدريب المهني (VTC Hub) ⚡',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14.5),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'140 مهنة • 8 قطاعات صناعية • مختبر فحص المركبات الكهربائية (EV)',
|
'140 مهنة • 8 قطاعات صناعية • مختبر فحص المركبات الكهربائية (EV)',
|
||||||
style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 11.5, fontWeight: FontWeight.w600),
|
style: TextStyle(
|
||||||
|
color: Color(0xFF6EE7B7),
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(CupertinoIcons.chevron_back, color: Color(0xFF10B981), size: 18),
|
const Icon(CupertinoIcons.chevron_back,
|
||||||
|
color: Color(0xFF10B981), size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -578,7 +668,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const SmartErrorNotebookScreen()),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const SmartErrorNotebookScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -591,7 +682,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.4), width: 1.5),
|
border: Border.all(
|
||||||
|
color: const Color(0xFF8B5CF6).withOpacity(0.4),
|
||||||
|
width: 1.5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF8B5CF6).withOpacity(0.15),
|
color: const Color(0xFF8B5CF6).withOpacity(0.15),
|
||||||
@@ -611,7 +704,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.book_circle_fill, color: Colors.white, size: 24),
|
child: const Icon(CupertinoIcons.book_circle_fill,
|
||||||
|
color: Colors.white, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
@@ -622,24 +716,32 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'دفتر الأخطاء الذكي والمسار العلاجي 📓',
|
'دفتر الأخطاء الذكي والمسار العلاجي 📓',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14),
|
||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'· نشط',
|
'· نشط',
|
||||||
style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11),
|
style: TextStyle(
|
||||||
|
color: Color(0xFF34D399),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 3),
|
SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'رصد الأسئلة المتعثرة وسد الفجوات التراكمية بالذكاء الاصطناعي',
|
'رصد الأسئلة المتعثرة وسد الفجوات التراكمية بالذكاء الاصطناعي',
|
||||||
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 11.5),
|
style: TextStyle(
|
||||||
|
color: Color(0xFF94A3B8), fontSize: 11.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(CupertinoIcons.chevron_back, color: Color(0xFF8B5CF6), size: 18),
|
const Icon(CupertinoIcons.chevron_back,
|
||||||
|
color: Color(0xFF8B5CF6), size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -658,17 +760,21 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _showGradeSelectionModal(context),
|
onTap: () => _showGradeSelectionModal(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.saqelCyan.withAlpha(30),
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
|
border: Border.all(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(80),
|
||||||
|
width: 0.8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'),
|
GradeLevelModel.getGradeName(
|
||||||
|
widget.user.gradeLevel ?? 'grade_10'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -676,7 +782,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const Icon(CupertinoIcons.pencil, color: AppColors.saqelCyan, size: 11),
|
const Icon(CupertinoIcons.pencil,
|
||||||
|
color: AppColors.saqelCyan, size: 11),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -684,12 +791,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
AppStrings.readinessScore,
|
AppStrings.readinessScore,
|
||||||
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
|
style: AppTypography.headlineMedium
|
||||||
|
.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'تقييم حقيقي مبني على الكويزات وفحوصات الفهم',
|
'تقييم حقيقي مبني على الكويزات وفحوصات الفهم',
|
||||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark, fontSize: 12),
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -699,12 +809,16 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
height: 64,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: AppColors.saqelCyan, width: 3),
|
border:
|
||||||
|
Border.all(color: AppColors.saqelCyan, width: 3),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${state.readinessScore.toStringAsFixed(0)}%',
|
'${state.readinessScore.toStringAsFixed(0)}%',
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.white),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -716,7 +830,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
// Real Lessons List from API
|
// Real Lessons List from API
|
||||||
Text(
|
Text(
|
||||||
AppStrings.tawjihiCurriculum,
|
AppStrings.tawjihiCurriculum,
|
||||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
style:
|
||||||
|
AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
@@ -751,7 +866,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
color: AppColors.appleBlue.withAlpha(30),
|
color: AppColors.appleBlue.withAlpha(30),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(CupertinoIcons.play_fill, color: AppColors.appleBlue, size: 20),
|
child: const Icon(CupertinoIcons.play_fill,
|
||||||
|
color: AppColors.appleBlue, size: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -771,14 +887,18 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
if (lesson.isAiVersion) ...[
|
if (lesson.isAiVersion) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.adminViolet.withAlpha(40),
|
color: AppColors.adminViolet.withAlpha(40),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'ذكاء اصطناعي ✨',
|
'ذكاء اصطناعي ✨',
|
||||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFFA78BFA)),
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFFA78BFA)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -789,12 +909,16 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
lesson.title,
|
lesson.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white),
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
lesson.formattedDuration,
|
lesson.formattedDuration,
|
||||||
style: const TextStyle(fontSize: 11, color: AppColors.textMutedDark),
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: AppColors.textMutedDark),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -818,9 +942,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
|
const Icon(CupertinoIcons.exclamationmark_triangle,
|
||||||
|
color: AppColors.errorRed, size: 40),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(state.message, style: const TextStyle(color: Colors.white)),
|
Text(state.message,
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _refreshData,
|
onPressed: _refreshData,
|
||||||
@@ -834,9 +960,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(CupertinoIcons.person_2, color: AppColors.textMutedDark, size: 48),
|
const Icon(CupertinoIcons.person_2,
|
||||||
|
color: AppColors.textMutedDark, size: 48),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(AppStrings.noChildrenLinked, style: const TextStyle(color: AppColors.textSecondaryDark)),
|
Text(AppStrings.noChildrenLinked,
|
||||||
|
style: const TextStyle(color: AppColors.textSecondaryDark)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -850,7 +978,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppStrings.linkedChildren,
|
AppStrings.linkedChildren,
|
||||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
style:
|
||||||
|
AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
@@ -861,15 +990,20 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
final child = state.children[index];
|
final child = state.children[index];
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => context.read<GuardianCubit>().selectChild(index),
|
onTap: () =>
|
||||||
|
context.read<GuardianCubit>().selectChild(index),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: EdgeInsets.only(left: index == 0 ? 8 : 0),
|
margin: EdgeInsets.only(left: index == 0 ? 8 : 0),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? AppColors.guardianAmber.withAlpha(35) : AppColors.darkCard,
|
color: isSelected
|
||||||
|
? AppColors.guardianAmber.withAlpha(35)
|
||||||
|
: AppColors.darkCard,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? AppColors.guardianAmber : AppColors.darkCardBorder,
|
color: isSelected
|
||||||
|
? AppColors.guardianAmber
|
||||||
|
: AppColors.darkCardBorder,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -880,13 +1014,17 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: isSelected ? const Color(0xFFFBBF24) : Colors.white,
|
color: isSelected
|
||||||
|
? const Color(0xFFFBBF24)
|
||||||
|
: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
child.gradeLevel ?? 'طالب مسجل',
|
child.gradeLevel ?? 'طالب مسجل',
|
||||||
style: const TextStyle(fontSize: 10.5, color: AppColors.textMutedDark),
|
style: const TextStyle(
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: AppColors.textMutedDark),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -906,11 +1044,13 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'تقرير الطالب: ${selectedChild.name}',
|
'تقرير الطالب: ${selectedChild.name}',
|
||||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
style: AppTypography.titleMedium
|
||||||
|
.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.teacherEmerald.withAlpha(30),
|
color: AppColors.teacherEmerald.withAlpha(30),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
@@ -929,12 +1069,14 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'المدرسة: ${selectedChild.schoolName ?? 'مدرسة الثقافة العسكرية'}',
|
'المدرسة: ${selectedChild.schoolName ?? 'مدرسة الثقافة العسكرية'}',
|
||||||
style: const TextStyle(fontSize: 12, color: AppColors.textMutedDark),
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: AppColors.textMutedDark),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(color: AppColors.darkCardBorder),
|
const Divider(color: AppColors.darkCardBorder),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
_buildGuardianStat('الامتحانات المنجزة بنجاح',
|
||||||
|
'${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
@@ -958,7 +1100,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
_buildGuardianStat('الرقم الوطني المشفر',
|
||||||
|
selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -972,7 +1115,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
builder: (ctx) => Directionality(
|
builder: (ctx) => Directionality(
|
||||||
textDirection: TextDirection.rtl,
|
textDirection: TextDirection.rtl,
|
||||||
child: CupertinoAlertDialog(
|
child: CupertinoAlertDialog(
|
||||||
title: const Text('ومضة التقرير الشهري لولي الأمر 📲'),
|
title:
|
||||||
|
const Text('ومضة التقرير الشهري لولي الأمر 📲'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n'
|
'🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n'
|
||||||
'الطالب: ${selectedChild.name}\n'
|
'الطالب: ${selectedChild.name}\n'
|
||||||
@@ -999,7 +1143,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
borderColor: AppColors.whatsappGreen.withAlpha(90),
|
borderColor: AppColors.whatsappGreen.withAlpha(90),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(CupertinoIcons.chat_bubble_2_fill, color: AppColors.whatsappGreen, size: 26),
|
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||||
|
color: AppColors.whatsappGreen, size: 26),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1009,24 +1154,33 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'تقارير بوابة نبيه للواتساب 📲',
|
'تقارير بوابة نبيه للواتساب 📲',
|
||||||
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white),
|
||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'· نشطة',
|
'· نشطة',
|
||||||
style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11),
|
style: TextStyle(
|
||||||
|
color: Color(0xFF34D399),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 3),
|
SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'انقر لمعاينة ومضة الأداء الأكاديمي الشهرية المرسلة لولي الأمر',
|
'انقر لمعاينة ومضة الأداء الأكاديمي الشهرية المرسلة لولي الأمر',
|
||||||
style: TextStyle(fontSize: 11, color: AppColors.textSecondaryDark),
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textSecondaryDark),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(CupertinoIcons.chevron_back, color: AppColors.whatsappGreen, size: 16),
|
Icon(CupertinoIcons.chevron_back,
|
||||||
|
color: AppColors.whatsappGreen, size: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1045,9 +1199,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondaryDark)),
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: AppColors.textSecondaryDark)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(value, style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: Colors.white)),
|
Text(value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+67
-81
@@ -27,6 +27,7 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
final ErrorNotebookRepository _repository = ErrorNotebookRepository();
|
final ErrorNotebookRepository _repository = ErrorNotebookRepository();
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _loadFailed = false;
|
||||||
ErrorNotebookSummary? _summary;
|
ErrorNotebookSummary? _summary;
|
||||||
List<ErrorNotebookItem> _items = [];
|
List<ErrorNotebookItem> _items = [];
|
||||||
|
|
||||||
@@ -42,15 +43,27 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
|
|
||||||
Future<void> _loadNotebookData() async {
|
Future<void> _loadNotebookData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final data = (widget.isGuardianMode && widget.studentId != null)
|
try {
|
||||||
? await _repository.getChildErrorNotebook(widget.studentId!)
|
final data = (widget.isGuardianMode && widget.studentId != null)
|
||||||
: await _repository.getErrorNotebook();
|
? await _repository.getChildErrorNotebook(widget.studentId!)
|
||||||
if (mounted) {
|
: await _repository.getErrorNotebook();
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_summary = data['summary'] as ErrorNotebookSummary?;
|
setState(() {
|
||||||
_items = (data['items'] as List<ErrorNotebookItem>?) ?? [];
|
_summary = data['summary'] as ErrorNotebookSummary?;
|
||||||
_isLoading = false;
|
_items = (data['items'] as List<ErrorNotebookItem>?) ?? [];
|
||||||
});
|
_loadFailed = false;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_summary = null;
|
||||||
|
_items = [];
|
||||||
|
_loadFailed = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +159,8 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
? const Center(
|
? const Center(
|
||||||
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
|
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
|
||||||
)
|
)
|
||||||
|
: _loadFailed
|
||||||
|
? Center(child: TextButton(onPressed: _loadNotebookData, child: const Text('تعذر تحميل دفتر الأخطاء. اضغط لإعادة المحاولة.')))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _loadNotebookData,
|
onRefresh: _loadNotebookData,
|
||||||
color: const Color(0xFF38BDF8),
|
color: const Color(0xFF38BDF8),
|
||||||
@@ -647,7 +662,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ElevatedButton.icon(
|
: ElevatedButton.icon(
|
||||||
onPressed: () => _startRemediation(item),
|
onPressed: item.isInRemediation && !item.retentionIsDue
|
||||||
|
? null
|
||||||
|
: () => _startRemediation(item),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
item.isInRemediation
|
item.isInRemediation
|
||||||
? CupertinoIcons.arrow_2_circlepath_circle_fill
|
? CupertinoIcons.arrow_2_circlepath_circle_fill
|
||||||
@@ -656,7 +673,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
item.isInRemediation
|
item.isInRemediation
|
||||||
? 'بدء اختبار الاسترجاع والتثبيت (تكرار متباعد 7 أيام) 🎯'
|
? (item.retentionIsDue
|
||||||
|
? 'بدء اختبار الاسترجاع بعد 7 أيام 🎯'
|
||||||
|
: 'اختبار الاسترجاع متاح بعد موعد المراجعة')
|
||||||
: 'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀',
|
: 'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀',
|
||||||
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
@@ -723,11 +742,14 @@ class _RemediationModalSheet extends StatefulWidget {
|
|||||||
class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
||||||
bool _isLoadingQuiz = true;
|
bool _isLoadingQuiz = true;
|
||||||
List<RemedialQuestion> _questions = [];
|
List<RemedialQuestion> _questions = [];
|
||||||
|
String _attemptUuid = '';
|
||||||
|
final List<int> _answers = [];
|
||||||
int _currentQuestionIndex = 0;
|
int _currentQuestionIndex = 0;
|
||||||
int? _selectedOptionIndex;
|
int? _selectedOptionIndex;
|
||||||
bool _hasAnsweredCurrent = false;
|
|
||||||
int _correctAnswersCount = 0;
|
int _correctAnswersCount = 0;
|
||||||
bool _isCompleted = false;
|
bool _isCompleted = false;
|
||||||
|
bool _passed = false;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -736,44 +758,43 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchQuiz() async {
|
Future<void> _fetchQuiz() async {
|
||||||
final qs = await widget.repository.getRemediationQuiz(
|
final result = await widget.repository.getRemediationQuiz(
|
||||||
errorUuid: widget.errorItem.uuid,
|
errorUuid: widget.errorItem.uuid,
|
||||||
topicName: widget.errorItem.topicName,
|
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_questions = qs;
|
_questions = result['questions'] as List<RemedialQuestion>;
|
||||||
|
_attemptUuid = result['attempt_uuid'] as String;
|
||||||
_isLoadingQuiz = false;
|
_isLoadingQuiz = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitAnswer() {
|
Future<void> _nextQuestion() async {
|
||||||
if (_selectedOptionIndex == null || _hasAnsweredCurrent) return;
|
if (_selectedOptionIndex == null || _isSubmitting) return;
|
||||||
|
_answers.add(_questions[_currentQuestionIndex].options[_selectedOptionIndex!].id);
|
||||||
final q = _questions[_currentQuestionIndex];
|
|
||||||
final isCorrect = _selectedOptionIndex == q.correctIndex;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_hasAnsweredCurrent = true;
|
|
||||||
if (isCorrect) {
|
|
||||||
_correctAnswersCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _nextQuestion() async {
|
|
||||||
if (_currentQuestionIndex < _questions.length - 1) {
|
if (_currentQuestionIndex < _questions.length - 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentQuestionIndex++;
|
_currentQuestionIndex++;
|
||||||
_selectedOptionIndex = null;
|
_selectedOptionIndex = null;
|
||||||
_hasAnsweredCurrent = false;
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Completed drill
|
setState(() => _isSubmitting = true);
|
||||||
setState(() => _isCompleted = true);
|
final result = await widget.repository.resolveError(widget.errorItem.uuid, _attemptUuid, _answers);
|
||||||
if (_correctAnswersCount >= 2) {
|
if (!mounted) return;
|
||||||
await widget.repository.resolveError(widget.errorItem.uuid);
|
if (result == null) {
|
||||||
|
_answers.removeLast();
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('تعذر اعتماد المحاولة. حاول مرة أخرى.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_correctAnswersCount = (result['correct_count'] as num?)?.toInt() ?? 0;
|
||||||
|
_passed = result['passed'] == true;
|
||||||
|
_isSubmitting = false;
|
||||||
|
_isCompleted = true;
|
||||||
|
});
|
||||||
|
if (_passed) {
|
||||||
widget.onRemediationCompleted();
|
widget.onRemediationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -795,6 +816,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
? const Center(
|
? const Center(
|
||||||
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
|
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
|
||||||
)
|
)
|
||||||
|
: _questions.isEmpty
|
||||||
|
? const Center(child: Text('لا تتوفر أسئلة مراجعة معتمدة لهذا المفهوم حالياً.', style: TextStyle(color: Colors.white70)))
|
||||||
: _isCompleted
|
: _isCompleted
|
||||||
? _buildCompletionState()
|
? _buildCompletionState()
|
||||||
: _buildQuestionState(),
|
: _buildQuestionState(),
|
||||||
@@ -856,30 +879,19 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: q.options.length,
|
itemCount: q.options.length,
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
final optText = q.options[idx];
|
final optText = q.options[idx].text;
|
||||||
final isSel = _selectedOptionIndex == idx;
|
final isSel = _selectedOptionIndex == idx;
|
||||||
final isCorrectOpt = idx == q.correctIndex;
|
|
||||||
|
|
||||||
Color borderCol = const Color(0xFF1E293B);
|
Color borderCol = const Color(0xFF1E293B);
|
||||||
Color bgCol = const Color(0xFF161F30);
|
Color bgCol = const Color(0xFF161F30);
|
||||||
|
|
||||||
if (_hasAnsweredCurrent) {
|
if (isSel) {
|
||||||
if (isCorrectOpt) {
|
|
||||||
borderCol = const Color(0xFF10B981);
|
|
||||||
bgCol = const Color(0xFF064E3B);
|
|
||||||
} else if (isSel) {
|
|
||||||
borderCol = const Color(0xFFEF4444);
|
|
||||||
bgCol = const Color(0xFF7F1D1D);
|
|
||||||
}
|
|
||||||
} else if (isSel) {
|
|
||||||
borderCol = const Color(0xFF38BDF8);
|
borderCol = const Color(0xFF38BDF8);
|
||||||
bgCol = const Color(0xFF0369A1);
|
bgCol = const Color(0xFF0369A1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: _hasAnsweredCurrent
|
onTap: () => setState(() => _selectedOptionIndex = idx),
|
||||||
? null
|
|
||||||
: () => setState(() => _selectedOptionIndex = idx),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -926,34 +938,11 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Explanation if answered
|
|
||||||
if (_hasAnsweredCurrent)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF1E293B),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
q.explanation,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFFCBD5E1),
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Action Button
|
// Action Button
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: !_hasAnsweredCurrent
|
onPressed: _selectedOptionIndex != null && !_isSubmitting ? _nextQuestion : null,
|
||||||
? (_selectedOptionIndex != null ? _submitAnswer : null)
|
|
||||||
: _nextQuestion,
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _hasAnsweredCurrent
|
backgroundColor: const Color(0xFF38BDF8),
|
||||||
? const Color(0xFF10B981)
|
|
||||||
: const Color(0xFF38BDF8),
|
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -961,11 +950,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
!_hasAnsweredCurrent
|
_isSubmitting ? 'جارٍ تصحيح الإجابات...' : (_currentQuestionIndex < _questions.length - 1
|
||||||
? 'تأكيد الإجابة'
|
? 'السؤال التالي ➔' : 'إنهاء وتصحيح المحاولة'),
|
||||||
: (_currentQuestionIndex < _questions.length - 1
|
|
||||||
? 'السؤال التالي ➔'
|
|
||||||
: 'إنهاء المسار العلاجي 🏆'),
|
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -974,7 +960,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCompletionState() {
|
Widget _buildCompletionState() {
|
||||||
final passed = _correctAnswersCount >= 2;
|
final passed = _passed;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1005,7 +991,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
|
|||||||
Text(
|
Text(
|
||||||
passed
|
passed
|
||||||
? (widget.errorItem.isInRemediation
|
? (widget.errorItem.isInRemediation
|
||||||
? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم. تم تثبيت المفهوم في الذاكرة طويلة المدى وترحيله إلى المتقنات 🏆.'
|
? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم في اختبار الاسترجاع بعد أسبوع.'
|
||||||
: 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة. دخل المفهوم مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام).')
|
: 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة. دخل المفهوم مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام).')
|
||||||
: 'أجبت عن $_correctAnswersCount من ${_questions.length}. حاول مجدداً لضمان إتقان السؤال في الامتحان الوزاري.',
|
: 'أجبت عن $_correctAnswersCount من ${_questions.length}. حاول مجدداً لضمان إتقان السؤال في الامتحان الوزاري.',
|
||||||
style: const TextStyle(fontSize: 13, color: Color(0xFF94A3B8)),
|
style: const TextStyle(fontSize: 13, color: Color(0xFF94A3B8)),
|
||||||
|
|||||||
+723
-949
File diff suppressed because it is too large
Load Diff
@@ -368,7 +368,9 @@ class Grade10LabsRegistry {
|
|||||||
|
|
||||||
/// Whether to surface draft / unreleased virtual labs for local inspection and review.
|
/// Whether to surface draft / unreleased virtual labs for local inspection and review.
|
||||||
/// Defaults to true in development so that the reviewer/teacher can immediately test the labs.
|
/// Defaults to true in development so that the reviewer/teacher can immediately test the labs.
|
||||||
static bool previewModeEnabled = true;
|
// Student builds must show released labs only. Draft previews stay available
|
||||||
|
// through explicit authoring/review tooling, never through the student flow.
|
||||||
|
static bool previewModeEnabled = false;
|
||||||
|
|
||||||
/// Exact identity lookup strictly isolated per subject.
|
/// Exact identity lookup strictly isolated per subject.
|
||||||
/// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing.
|
/// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing.
|
||||||
|
|||||||
@@ -126,9 +126,6 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
|
|||||||
final labs = candidateLabs;
|
final labs = candidateLabs;
|
||||||
|
|
||||||
if (labs.isEmpty) {
|
if (labs.isEmpty) {
|
||||||
if (widget.specializedToolWidget != null) {
|
|
||||||
return widget.specializedToolWidget!;
|
|
||||||
}
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@@ -154,16 +151,6 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
|
|||||||
color: Color(0xFF94A3B8), fontSize: 11.5, height: 1.6),
|
color: Color(0xFF94A3B8), fontSize: 11.5, height: 1.6),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _openGallery(context),
|
|
||||||
icon: const Icon(CupertinoIcons.square_grid_2x2, size: 16),
|
|
||||||
label: const Text('معرض المعاينة التأليفية',
|
|
||||||
style:
|
|
||||||
TextStyle(fontWeight: FontWeight.w800, fontSize: 12.5)),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.saqelCyan,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ void main() {
|
|||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
ratingCount: 95,
|
ratingCount: 95,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
durationSeconds: 1200,
|
||||||
),
|
),
|
||||||
const PublishedLessonVideoModel(
|
const PublishedLessonVideoModel(
|
||||||
videoVersionId: 'v_khalid',
|
videoVersionId: 'v_khalid',
|
||||||
@@ -67,6 +68,7 @@ void main() {
|
|||||||
rating: 4.9,
|
rating: 4.9,
|
||||||
ratingCount: 140,
|
ratingCount: 140,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
durationSeconds: 1200,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ void main() {
|
|||||||
context,
|
context,
|
||||||
lesson: lesson,
|
lesson: lesson,
|
||||||
videos: videos,
|
videos: videos,
|
||||||
|
nextCursor: null,
|
||||||
|
availableCount: videos.length,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('افتح اختيار المعلم'),
|
child: const Text('افتح اختيار المعلم'),
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ void main() {
|
|||||||
await t.pump();
|
await t.pump();
|
||||||
expect(find.text('تجارب ومختبرات الفيزياء'), findsNothing);
|
expect(find.text('تجارب ومختبرات الفيزياء'), findsNothing);
|
||||||
expect(find.text('لا مختبرات منشورة لمبحث الفيزياء بعد'), findsOneWidget);
|
expect(find.text('لا مختبرات منشورة لمبحث الفيزياء بعد'), findsOneWidget);
|
||||||
expect(find.text('معرض المعاينة التأليفية'), findsOneWidget);
|
expect(find.text('معرض المعاينة التأليفية'), findsNothing);
|
||||||
Grade10LabsRegistry.previewModeEnabled = true;
|
Grade10LabsRegistry.previewModeEnabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -911,4 +911,3 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AppConfig {
|
|||||||
static const String auditVideoEndpoint = '/api/teacher/audit-studio-video';
|
static const String auditVideoEndpoint = '/api/teacher/audit-studio-video';
|
||||||
static const String uploadLessonEndpoint = '/api/teacher/lessons/upload';
|
static const String uploadLessonEndpoint = '/api/teacher/lessons/upload';
|
||||||
static const String uploadVideoEndpoint = '/api/teacher/videos/upload-direct';
|
static const String uploadVideoEndpoint = '/api/teacher/videos/upload-direct';
|
||||||
static const String curriculumTreeEndpoint = '/api/curriculum/tree';
|
static const String curriculumTreeEndpoint = '/api/teacher/curriculum/intake-tree';
|
||||||
static const String qnaEndpoint = '/api/teacher/qna';
|
static const String qnaEndpoint = '/api/teacher/qna';
|
||||||
static const String cliqPayoutEndpoint = '/api/teacher/payout/request';
|
static const String cliqPayoutEndpoint = '/api/teacher/payout/request';
|
||||||
|
|
||||||
|
|||||||
@@ -517,10 +517,9 @@ class AuthController
|
|||||||
if ($user) {
|
if ($user) {
|
||||||
$rawGrade = (string)($user['grade_level'] ?? 'grade_10');
|
$rawGrade = (string)($user['grade_level'] ?? 'grade_10');
|
||||||
$normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
$normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
||||||
if ($rawGrade === 'tawjihi_2008' || $rawGrade !== $normGrade) {
|
// Profile reads may normalize presentation, but must not rewrite
|
||||||
Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$normGrade, $userId]);
|
// a student's academic record as a side effect of GET.
|
||||||
$user['grade_level'] = $normGrade;
|
$user['grade_level'] = $normGrade;
|
||||||
}
|
|
||||||
$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'],
|
||||||
@@ -586,11 +585,6 @@ 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
|
||||||
@@ -736,41 +730,14 @@ class AuthController
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Legacy / Fallback for already logged-in students updating profile
|
// This endpoint accepts only a pending student-identity proof.
|
||||||
$studentId = (int)$request->user_id;
|
// A bare JWT here previously bypassed the active-session and role
|
||||||
if (!$studentId) {
|
// checks and allowed changing a national ID without re-verification.
|
||||||
$authHeader = $request->getHeader('authorization', '');
|
$response->status(401)->json([
|
||||||
if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
'status' => 'identity_proof_required',
|
||||||
$payload = Security::verifyJWT($matches[1]);
|
'message' => 'إكمال الملف يتطلب رمز التحقق من الهوية. لتغيير صف طالب مستقل استخدم مسار تغيير الصف المصادق.',
|
||||||
if ($payload && isset($payload['user_id'])) {
|
|
||||||
$studentId = (int)$payload['user_id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!$studentId) {
|
|
||||||
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Database::query(
|
|
||||||
"UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), national_id_hash = IF(? != '', ?, national_id_hash), updated_at = NOW() WHERE id = ?",
|
|
||||||
[$fullName, $gradeLevel, $stream, $nationalId, Security::encrypt($nationalId), $nationalId, Security::blindIndex($nationalId), $studentId]
|
|
||||||
);
|
|
||||||
|
|
||||||
$student = Database::selectOne("SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId]);
|
|
||||||
|
|
||||||
$response->json([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => 'تم استكمال ملف الطالب بنجاح! مرحباً بك في منصة صَقِل',
|
|
||||||
'user' => [
|
|
||||||
'id' => $student['id'],
|
|
||||||
'uuid' => $student['uuid'],
|
|
||||||
'full_name' => $student['full_name'],
|
|
||||||
'grade_level' => $student['grade_level'],
|
|
||||||
'stream' => $student['stream'],
|
|
||||||
'role' => 'student'
|
|
||||||
]
|
|
||||||
]);
|
]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,30 +748,45 @@ class AuthController
|
|||||||
public function updateStudentGrade(Request $request, Response $response): void
|
public function updateStudentGrade(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$studentId = (int)$request->user_id;
|
$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) {
|
if (!$studentId) {
|
||||||
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
|
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = $request->getBody();
|
$body = $request->getBody();
|
||||||
$rawGrade = trim((string)($body['grade_level'] ?? 'grade_10'));
|
$rawGrade = trim((string)($body['grade_level'] ?? ''));
|
||||||
$stream = trim((string)($body['stream'] ?? 'general'));
|
$stream = trim((string)($body['stream'] ?? ''));
|
||||||
|
|
||||||
$normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
$normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
||||||
|
if (!preg_match('/^grade_(?:[1-9]|1[0-2])$/', $normalizedGrade)
|
||||||
|
|| !in_array($stream, ['scientific', 'literary', 'vocational', 'general'], true)) {
|
||||||
|
$response->status(400)->json(['status' => 'error', 'message' => 'الصف أو المسار غير صالح.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Database::query(
|
$scope = Database::selectOne('SELECT id, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
|
||||||
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?",
|
if (!$scope) {
|
||||||
|
$response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!empty($scope['school_id']) || !empty($scope['is_school_sponsored'])) {
|
||||||
|
$response->status(403)->json(['status' => 'forbidden', 'message' => 'صف الطالب المدرسي يحدّثه مسؤول المدرسة من كشف الانتساب.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = Database::execute(
|
||||||
|
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW()
|
||||||
|
WHERE id = ? AND school_id IS NULL AND is_school_sponsored = 0",
|
||||||
[$normalizedGrade, $stream, $studentId]
|
[$normalizedGrade, $stream, $studentId]
|
||||||
);
|
);
|
||||||
|
if ($changed !== 1) {
|
||||||
|
$after = Database::selectOne('SELECT grade_level, stream, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
|
||||||
|
if (!$after || !empty($after['school_id']) || !empty($after['is_school_sponsored'])
|
||||||
|
|| (string)$after['grade_level'] !== $normalizedGrade || (string)$after['stream'] !== $stream) {
|
||||||
|
$response->status(409)->json(['status' => 'conflict', 'message' => 'تغير نطاق حساب الطالب؛ أعد تحميل ملفك الدراسي.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$student = Database::selectOne(
|
$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
|
"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
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ use App\Services\CurriculumService;
|
|||||||
use App\Services\CurriculumExtractorService;
|
use App\Services\CurriculumExtractorService;
|
||||||
use App\Services\PublishedContentService;
|
use App\Services\PublishedContentService;
|
||||||
use App\Services\LearningPackageService;
|
use App\Services\LearningPackageService;
|
||||||
|
use App\Services\PublishedCurriculumTreeFilter;
|
||||||
|
use App\Services\AssetDownloadTicketService;
|
||||||
|
|
||||||
class CurriculumController
|
class CurriculumController
|
||||||
{
|
{
|
||||||
@@ -207,13 +209,23 @@ class CurriculumController
|
|||||||
$removeUnpublishedResources($tree);
|
$removeUnpublishedResources($tree);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$approvedLessons = Database::select("SELECT id, uuid, source_manifest_path FROM curriculum_lessons WHERE source_status='approved'");
|
$approvedLessons = Database::select(
|
||||||
|
"SELECT cl.id, cl.uuid, cl.source_manifest_path, a.uuid AS primary_asset_id
|
||||||
|
FROM curriculum_lessons cl
|
||||||
|
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.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
|
||||||
|
WHERE cl.source_status='approved'
|
||||||
|
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
|
||||||
|
);
|
||||||
$byPath = [];
|
$byPath = [];
|
||||||
$lessonMap = [];
|
$lessonMap = [];
|
||||||
foreach ($approvedLessons as $row) {
|
foreach ($approvedLessons as $row) {
|
||||||
$p = (string)$row['source_manifest_path'];
|
$p = (string)$row['source_manifest_path'];
|
||||||
$byPath[$p] = [
|
$byPath[$p] = [
|
||||||
'curriculum_lesson_id' => (string)$row['uuid'],
|
'curriculum_lesson_id' => (string)$row['uuid'],
|
||||||
|
'primary_lesson_asset_id' => (string)$row['primary_asset_id'],
|
||||||
'has_video' => false,
|
'has_video' => false,
|
||||||
];
|
];
|
||||||
$lessonMap[(int)$row['id']] = $p;
|
$lessonMap[(int)$row['id']] = $p;
|
||||||
@@ -224,7 +236,13 @@ class CurriculumController
|
|||||||
$videoCounts = Database::select(
|
$videoCounts = Database::select(
|
||||||
"SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count
|
"SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count
|
||||||
FROM teacher_submissions ts
|
FROM teacher_submissions ts
|
||||||
|
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
|
||||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||||
|
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'
|
||||||
WHERE ts.status = 'published'
|
WHERE ts.status = 'published'
|
||||||
GROUP BY ts.curriculum_lesson_id"
|
GROUP BY ts.curriculum_lesson_id"
|
||||||
);
|
);
|
||||||
@@ -263,7 +281,7 @@ class CurriculumController
|
|||||||
// bundle, and expose an opaque asset UUID rather than a storage path.
|
// bundle, and expose an opaque asset UUID rather than a storage path.
|
||||||
$resourceRows = Database::select(
|
$resourceRows = Database::select(
|
||||||
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, a.source_reference,
|
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, a.source_reference,
|
||||||
cl.title AS lesson_title, cl.unit_key,
|
cl.title AS lesson_title, cl.semester_key, cl.unit_key,
|
||||||
pba.role, pba.sort_order
|
pba.role, pba.sort_order
|
||||||
FROM publication_bundles pb
|
FROM publication_bundles pb
|
||||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||||
@@ -292,7 +310,8 @@ class CurriculumController
|
|||||||
|
|
||||||
$title = '';
|
$title = '';
|
||||||
if ($group === 'textbooks') {
|
if ($group === 'textbooks') {
|
||||||
$ref = strtolower((string)($row['source_reference'] ?? ''));
|
$sourceReference = trim((string)($row['source_reference'] ?? ''));
|
||||||
|
$ref = strtolower($sourceReference);
|
||||||
$subjectNames = [
|
$subjectNames = [
|
||||||
'math' => 'الرياضيات',
|
'math' => 'الرياضيات',
|
||||||
'physics' => 'الفيزياء',
|
'physics' => 'الفيزياء',
|
||||||
@@ -315,13 +334,19 @@ class CurriculumController
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$part = '';
|
$sourceBasename = basename(str_replace('\\', '/', $sourceReference));
|
||||||
if (str_contains($ref, 'part1') || str_contains($ref, 'part_1') || str_contains($ref, 'semester_1')) {
|
$sourceTitle = trim(pathinfo($sourceBasename, PATHINFO_FILENAME));
|
||||||
$part = ' (الفصل الأول)';
|
if ($sourceTitle !== '') {
|
||||||
} elseif (str_contains($ref, 'part2') || str_contains($ref, 'part_2') || str_contains($ref, 'semester_2')) {
|
$title = $sourceTitle;
|
||||||
$part = ' (الفصل الثاني)';
|
} else {
|
||||||
|
$semesterKey = strtolower((string)($row['semester_key'] ?? ''));
|
||||||
|
$semesterLabel = str_contains($semesterKey, '1')
|
||||||
|
? 'الفصل الأول'
|
||||||
|
: (str_contains($semesterKey, '2') ? 'الفصل الثاني' : 'فصل غير محدد');
|
||||||
|
$unitKey = preg_replace('/[^0-9]/', '', (string)($row['unit_key'] ?? ''));
|
||||||
|
$unitLabel = $unitKey !== '' ? " — الوحدة $unitKey" : '';
|
||||||
|
$title = "ملف PDF منشور للمادة: $foundSub — $semesterLabel$unitLabel";
|
||||||
}
|
}
|
||||||
$title = "الكتاب المدرسي الرسمي: $foundSub$part";
|
|
||||||
} else {
|
} else {
|
||||||
$lTitle = trim((string)($row['lesson_title'] ?? ''));
|
$lTitle = trim((string)($row['lesson_title'] ?? ''));
|
||||||
$title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية";
|
$title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية";
|
||||||
@@ -349,6 +374,78 @@ class CurriculumController
|
|||||||
$response->json(['status'=>'success','data'=>$tree]);
|
$response->json(['status'=>'success','data'=>$tree]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Public student catalogue: fail closed when publication evidence is unavailable. */
|
||||||
|
public function getPublishedTree(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = Database::select(
|
||||||
|
"SELECT cl.id, cl.uuid, cl.curriculum_version, cl.grade_key, cl.subject_key, cl.semester_key,
|
||||||
|
cl.unit_key, cl.lesson_key, cl.source_manifest_path, cl.title,
|
||||||
|
a.uuid AS primary_asset_id
|
||||||
|
FROM curriculum_lessons cl
|
||||||
|
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.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
|
||||||
|
WHERE cl.source_status='approved'
|
||||||
|
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
|
||||||
|
);
|
||||||
|
$videoRows = Database::select(
|
||||||
|
"SELECT DISTINCT ts.curriculum_lesson_id
|
||||||
|
FROM teacher_submissions ts
|
||||||
|
JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published'
|
||||||
|
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||||
|
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||||
|
JOIN publication_bundles pb ON pb.curriculum_lesson_id=ts.curriculum_lesson_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'
|
||||||
|
WHERE ts.status='published'"
|
||||||
|
);
|
||||||
|
$hasVideo = array_fill_keys(array_map(static fn($row) => (int)$row['curriculum_lesson_id'], $videoRows), true);
|
||||||
|
foreach ($rows as &$row) $row['has_video'] = isset($hasVideo[(int)$row['id']]);
|
||||||
|
unset($row);
|
||||||
|
$tree = PublishedCurriculumTreeFilter::filter(CurriculumService::getCurriculumTree(), $rows);
|
||||||
|
|
||||||
|
if ($tree !== []) {
|
||||||
|
$resources = Database::select(
|
||||||
|
"SELECT cl.grade_key, cl.subject_key, cl.title AS lesson_title,
|
||||||
|
pba.role, a.uuid AS asset_id, a.asset_type, a.mime_type
|
||||||
|
FROM publication_bundles pb
|
||||||
|
JOIN curriculum_lessons cl ON cl.id=pb.curriculum_lesson_id AND cl.source_status='approved'
|
||||||
|
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role IN ('textbook','worksheet')
|
||||||
|
JOIN content_assets a ON a.id=pba.content_asset_id AND a.review_status='approved' AND a.rights_status='cleared'
|
||||||
|
WHERE pb.status='published'
|
||||||
|
AND ((pba.role='textbook' AND a.asset_type='textbook_pdf')
|
||||||
|
OR (pba.role='worksheet' AND a.asset_type='worksheet_markdown'))
|
||||||
|
ORDER BY cl.grade_key, cl.subject_key, pba.role, pba.sort_order, a.id"
|
||||||
|
);
|
||||||
|
$seen = [];
|
||||||
|
foreach ($resources as $item) {
|
||||||
|
$gradeKey = (string)$item['grade_key'];
|
||||||
|
$subjectKey = (string)$item['subject_key'];
|
||||||
|
if (!isset($tree[$gradeKey]['subjects'][$subjectKey])) continue;
|
||||||
|
$assetId = (string)$item['asset_id'];
|
||||||
|
if (isset($seen[$gradeKey][$subjectKey][$assetId])) continue;
|
||||||
|
$seen[$gradeKey][$subjectKey][$assetId] = true;
|
||||||
|
$isBook = $item['role'] === 'textbook';
|
||||||
|
$group = $isBook ? 'textbooks' : 'worksheets';
|
||||||
|
$tree[$gradeKey]['subjects'][$subjectKey]['resources'][$group]['items'][] = [
|
||||||
|
'asset_id' => $assetId,
|
||||||
|
'asset_type' => (string)$item['asset_type'],
|
||||||
|
'mime_type' => (string)$item['mime_type'],
|
||||||
|
'type' => $isBook ? 'textbook' : 'worksheet',
|
||||||
|
'title' => $isBook ? 'كتاب المبحث المنشور' : 'ورقة عمل: ' . (string)$item['lesson_title'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$response->json(['status' => 'success', 'data' => $tree === [] ? new \stdClass() : $tree]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Published curriculum tree unavailable: ' . $e->getMessage());
|
||||||
|
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تحميل فهرس الدروس المنشورة.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Single Lesson Markdown Content
|
* Get Single Lesson Markdown Content
|
||||||
*/
|
*/
|
||||||
@@ -360,13 +457,15 @@ class CurriculumController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$content = CurriculumService::getLessonMarkdown($file);
|
$content = CurriculumService::getLessonMarkdown($file);
|
||||||
|
if ($content === '') {
|
||||||
|
$response->status(404)->json(['status' => 'error', 'message' => 'ملف الدرس المطلوب غير موجود.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
$assets = CurriculumService::getLessonAiAssets($file);
|
$assets = CurriculumService::getLessonAiAssets($file);
|
||||||
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
|
|
||||||
|
|
||||||
$response->json([
|
$response->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'file' => $file,
|
'file' => $file,
|
||||||
'server_full_path' => $serverFullPath,
|
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'ai_assets' => $assets
|
'ai_assets' => $assets
|
||||||
]);
|
]);
|
||||||
@@ -384,34 +483,35 @@ class CurriculumController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CurriculumService::saveLessonMarkdown($file, $content);
|
try {
|
||||||
|
$published = Database::selectOne(
|
||||||
|
"SELECT a.id FROM content_assets a
|
||||||
|
JOIN publication_bundle_assets pba ON pba.content_asset_id=a.id
|
||||||
|
JOIN publication_bundles pb ON pb.id=pba.publication_bundle_id
|
||||||
|
WHERE a.storage_driver='local' AND a.storage_key=? AND pb.status='published'
|
||||||
|
LIMIT 1",
|
||||||
|
[$file]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Draft lesson save gate unavailable: ' . $e->getMessage());
|
||||||
|
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من حالة أصل الدرس؛ لم يُحفظ الملف.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($published) {
|
||||||
|
$response->status(409)->json(['status' => 'published_asset_immutable', 'message' => 'هذا الأصل منشور؛ أنشئ نسخة مسودة جديدة ومررها للمراجعة.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!CurriculumService::saveLessonMarkdown($file, $content)) {
|
||||||
|
$response->status(400)->json(['status' => 'error', 'message' => 'مسار ملف المسودة غير صالح أو تعذر حفظه.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($aiAssets !== null) {
|
if ($aiAssets !== null) {
|
||||||
CurriculumService::saveLessonAiAssets($file, $aiAssets);
|
CurriculumService::saveLessonAiAssets($file, $aiAssets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to MySQL Database Table `lessons` if connected
|
|
||||||
try {
|
|
||||||
$aiVideoUrl = $aiAssets['ai_video_url'] ?? null;
|
|
||||||
$cheatSheet = $aiAssets['cheat_sheet'] ?? null;
|
|
||||||
$socraticJson = isset($aiAssets['socratic_quiz']) ? json_encode($aiAssets['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null;
|
|
||||||
|
|
||||||
// Search lesson by matching filename or title
|
|
||||||
$filename = basename($file, '.md');
|
|
||||||
\App\Core\Database::query(
|
|
||||||
"UPDATE lessons SET markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json)
|
|
||||||
WHERE title LIKE ? OR markdown_content LIKE ?",
|
|
||||||
[$content, $aiVideoUrl, $cheatSheet, $socraticJson, "%{$filename}%", "%{$file}%"]
|
|
||||||
);
|
|
||||||
} catch (\Throwable $dbEx) {
|
|
||||||
error_log("Curriculum DB save sync note: " . $dbEx->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
|
|
||||||
|
|
||||||
$response->json([
|
$response->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'تم حفظ واعتماد محتوى الدرس في المنهاج وقاعدة البيانات بنجاح!',
|
'message' => 'تم حفظ مسودة الدرس. النشر يتطلب مراجعة المحتوى والحقوق والحزمة.',
|
||||||
'server_full_path' => $serverFullPath
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +734,9 @@ class CurriculumController
|
|||||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||||
try {
|
try {
|
||||||
$asset = PublishedContentService::findPublishedAsset($assetId);
|
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||||
|
if ($asset && !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
|
||||||
|
$asset = null;
|
||||||
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
error_log('Published asset lookup failed: ' . $e->getMessage());
|
error_log('Published asset lookup failed: ' . $e->getMessage());
|
||||||
$response->status(503)->json([
|
$response->status(503)->json([
|
||||||
@@ -660,6 +763,16 @@ class CurriculumController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actualSha = hash_file('sha256', $path);
|
||||||
|
if ($actualSha === false || !hash_equals((string)$asset['sha256'], $actualSha)
|
||||||
|
|| filesize($path) !== (int)$asset['byte_size']) {
|
||||||
|
$response->status(503)->json(['status' => 'unavailable', 'message' => 'فشل التحقق من سلامة الأصل المنشور.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$response->setHeader('Cache-Control', 'private, no-store');
|
||||||
|
$response->setHeader('Referrer-Policy', 'no-referrer');
|
||||||
|
$response->setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
|
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
|
||||||
$content = file_get_contents($path);
|
$content = file_get_contents($path);
|
||||||
if ($content === false) {
|
if ($content === false) {
|
||||||
@@ -682,6 +795,47 @@ class CurriculumController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Authenticated student obtains a short-lived browser grant for one PDF. */
|
||||||
|
public function issueAssetDownloadTicket(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||||
|
try {
|
||||||
|
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||||
|
if (!$asset || $asset['asset_type'] !== 'textbook_pdf'
|
||||||
|
|| $asset['mime_type'] !== 'application/pdf'
|
||||||
|
|| !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
|
||||||
|
$response->status(404)->json(['status' => 'error', 'message' => 'الكتاب غير منشور أو غير متاح لك.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$grant = AssetDownloadTicketService::issue((int)$request->user_id, $assetId);
|
||||||
|
$response->setHeader('Cache-Control', 'private, no-store');
|
||||||
|
$response->json(['status' => 'success', 'data' => $grant]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Asset download grant unavailable: ' . $e->getMessage());
|
||||||
|
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تجهيز فتح الكتاب حالياً.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser receives only a scoped grant, never the student's session JWT. */
|
||||||
|
public function downloadAssetWithTicket(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||||
|
$ticket = trim((string)$request->getQuery('ticket', ''));
|
||||||
|
try {
|
||||||
|
$studentId = AssetDownloadTicketService::resolve($ticket, $assetId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Asset download grant lookup unavailable: ' . $e->getMessage());
|
||||||
|
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من رابط الكتاب.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($studentId === null) {
|
||||||
|
$response->status(403)->json(['status' => 'forbidden', 'message' => 'رابط الكتاب غير صالح أو انتهت صلاحيته.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$request->user_id = $studentId;
|
||||||
|
$this->getPublishedAsset($request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
private static function assetMetadata(array $asset): array
|
private static function assetMetadata(array $asset): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ namespace App\Controllers;
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Request;
|
use App\Core\Request;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
|
use App\Services\RemediationAttemptGrader;
|
||||||
|
|
||||||
class ErrorNotebookController
|
class ErrorNotebookController
|
||||||
{
|
{
|
||||||
public function getErrorNotebook(Request $request, Response $response): void
|
public function getErrorNotebook(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$studentId = (int)$request->user_id;
|
$studentId = (int)$request->user_id;
|
||||||
$sql = "SELECT * FROM student_error_notebook WHERE student_id = ?";
|
$sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?";
|
||||||
$params = [$studentId];
|
$params = [$studentId];
|
||||||
$subject = trim((string)$request->getQuery('subject', ''));
|
$subject = trim((string)$request->getQuery('subject', ''));
|
||||||
$status = trim((string)$request->getQuery('status', ''));
|
$status = trim((string)$request->getQuery('status', ''));
|
||||||
@@ -69,9 +70,11 @@ class ErrorNotebookController
|
|||||||
|
|
||||||
public function getRemediationQuiz(Request $request, Response $response): void
|
public function getRemediationQuiz(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
$body = $request->getBody();
|
||||||
|
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||||
$error = Database::selectOne(
|
$error = Database::selectOne(
|
||||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
"SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due
|
||||||
|
FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||||
[$uuid, (int)$request->user_id]
|
[$uuid, (int)$request->user_id]
|
||||||
);
|
);
|
||||||
if (!$error) {
|
if (!$error) {
|
||||||
@@ -79,49 +82,62 @@ class ErrorNotebookController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$questions = [];
|
if ($error['status'] === 'mastered') {
|
||||||
|
$response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']);
|
||||||
// 1. Try fetching matching questions from database question bank
|
return;
|
||||||
$topicTag = '%' . $error['topic_name'] . '%';
|
}
|
||||||
|
if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) {
|
||||||
|
$response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (empty($error['lesson_id'])) {
|
||||||
|
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
$dbQuestions = Database::select(
|
$dbQuestions = Database::select(
|
||||||
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
|
"SELECT q.id, q.question_text FROM questions q
|
||||||
FROM questions q
|
|
||||||
JOIN exams e ON e.id = q.exam_id
|
JOIN exams e ON e.id = q.exam_id
|
||||||
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
|
WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||||
LIMIT 3",
|
AND q.question_type = 'multiple_choice'
|
||||||
[$topicTag, $topicTag, $topicTag]
|
AND q.remediation_review_status = 'approved'
|
||||||
|
AND q.remediation_reviewed_by IS NOT NULL
|
||||||
|
AND q.remediation_reviewed_at IS NOT NULL
|
||||||
|
AND q.remediation_source_reference IS NOT NULL
|
||||||
|
ORDER BY q.id ASC LIMIT 20",
|
||||||
|
[(int)$error['lesson_id'], $error['topic_name']]
|
||||||
);
|
);
|
||||||
|
$questions = [];
|
||||||
foreach ($dbQuestions as $dbQ) {
|
$answerKey = [];
|
||||||
$opts = Database::select(
|
foreach ($dbQuestions as $question) {
|
||||||
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
|
$options = Database::select(
|
||||||
[(int)$dbQ['id']]
|
'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC',
|
||||||
|
[(int)$question['id']]
|
||||||
);
|
);
|
||||||
if (count($opts) >= 2) {
|
if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue;
|
||||||
$optTexts = [];
|
$questions[] = [
|
||||||
$correctIdx = 0;
|
'id' => (int)$question['id'], 'question' => $question['question_text'],
|
||||||
foreach ($opts as $i => $opt) {
|
'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options),
|
||||||
$optTexts[] = $opt['option_text'];
|
];
|
||||||
if ((int)$opt['is_correct'] === 1) {
|
$answerKey[] = [
|
||||||
$correctIdx = $i;
|
'question_id' => (int)$question['id'],
|
||||||
}
|
'option_ids' => array_map(fn($o) => (int)$o['id'], $options),
|
||||||
}
|
'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'],
|
||||||
$questions[] = [
|
];
|
||||||
'id' => (int)$dbQ['id'],
|
if (count($questions) === 3) break;
|
||||||
'question' => $dbQ['question_text'],
|
}
|
||||||
'options' => $optTexts,
|
if (count($questions) !== 3) {
|
||||||
'correct_index' => $correctIdx,
|
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']);
|
||||||
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
|
return;
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
|
$attemptUuid = $this->uuid();
|
||||||
if (count($questions) < 2) {
|
Database::insert(
|
||||||
$topic = $error['topic_name'];
|
"INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at)
|
||||||
$subject = $error['subject_name'];
|
VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))",
|
||||||
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
|
[$attemptUuid, (int)$error['id'], (int)$request->user_id,
|
||||||
}
|
$error['status'] === 'in_remediation' ? 'retention' : 'initial',
|
||||||
|
json_encode(array_column($questions, 'id')), json_encode($answerKey)]
|
||||||
|
);
|
||||||
|
|
||||||
$response->json([
|
$response->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
@@ -129,6 +145,7 @@ class ErrorNotebookController
|
|||||||
'error_uuid' => $uuid,
|
'error_uuid' => $uuid,
|
||||||
'topic_name' => $error['topic_name'],
|
'topic_name' => $error['topic_name'],
|
||||||
'subject_name' => $error['subject_name'],
|
'subject_name' => $error['subject_name'],
|
||||||
|
'attempt_uuid' => $attemptUuid,
|
||||||
'questions' => $questions,
|
'questions' => $questions,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -138,111 +155,94 @@ class ErrorNotebookController
|
|||||||
{
|
{
|
||||||
$body = $request->getBody();
|
$body = $request->getBody();
|
||||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||||
|
$attemptUuid = trim((string)($body['attempt_uuid'] ?? ''));
|
||||||
$studentId = (int)$request->user_id;
|
$studentId = (int)$request->user_id;
|
||||||
|
$answers = $body['answers'] ?? null;
|
||||||
$error = Database::selectOne(
|
if (!is_array($answers) || count($answers) !== 3) {
|
||||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
$response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']);
|
||||||
[$uuid, $studentId]
|
|
||||||
);
|
|
||||||
if (!$error) {
|
|
||||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$answersHash = hash('sha256', json_encode($answers));
|
||||||
$isRetentionPhase = ($error['status'] === 'in_remediation');
|
$pdo = Database::getConnection();
|
||||||
$nextStatus = $isRetentionPhase ? 'mastered' : 'in_remediation';
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
// Stage 1: Initial Remedial Drill passed -> enters Spaced Repetition (in_remediation)
|
$error = Database::selectOne(
|
||||||
// Stage 2: Spaced Repetition Retention Challenge passed -> enters permanently Mastered
|
'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE',
|
||||||
Database::query(
|
[$uuid, $studentId]
|
||||||
"UPDATE student_error_notebook
|
|
||||||
SET status = ?,
|
|
||||||
mastered_at = IF(? = 'mastered', NOW(), mastered_at),
|
|
||||||
remediation_attempts_count = remediation_attempts_count + 1
|
|
||||||
WHERE id = ?",
|
|
||||||
[$nextStatus, $nextStatus, (int)$error['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update student mastery analytics and readiness
|
|
||||||
$currentMastery = Database::selectOne(
|
|
||||||
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
|
|
||||||
[$studentId]
|
|
||||||
);
|
|
||||||
if ($currentMastery) {
|
|
||||||
$readinessGain = $isRetentionPhase ? 1.5 : 0.6;
|
|
||||||
$masteryGain = $isRetentionPhase ? 1.2 : 0.5;
|
|
||||||
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + $readinessGain);
|
|
||||||
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + $masteryGain);
|
|
||||||
Database::query(
|
|
||||||
"UPDATE student_mastery_analytics
|
|
||||||
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
|
|
||||||
WHERE id = ?",
|
|
||||||
[$newReadiness, $newMastery, (int)$currentMastery['id']]
|
|
||||||
);
|
);
|
||||||
|
$attempt = $error ? Database::selectOne(
|
||||||
|
'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE',
|
||||||
|
[$attemptUuid, (int)$error['id'], $studentId]
|
||||||
|
) : null;
|
||||||
|
if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) {
|
||||||
|
$pdo->commit();
|
||||||
|
$response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed',
|
||||||
|
'correct_count' => (int)$attempt['correct_count'],
|
||||||
|
'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial';
|
||||||
|
if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase ||
|
||||||
|
($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = json_decode($attempt['question_ids_json'], true);
|
||||||
|
$answerKey = json_decode($attempt['answer_key_json'], true);
|
||||||
|
if (!is_array($ids) || !is_array($answerKey)) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($ids as $index => $questionId) {
|
||||||
|
$published = Database::selectOne(
|
||||||
|
"SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id
|
||||||
|
WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||||
|
AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL
|
||||||
|
AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE",
|
||||||
|
[(int)$questionId, (int)$error['lesson_id'], $error['topic_name']]
|
||||||
|
);
|
||||||
|
if (!$published) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers);
|
||||||
|
} catch (\InvalidArgumentException $invalidAnswer) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$passed = $correct >= 2;
|
||||||
|
Database::query(
|
||||||
|
"UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?",
|
||||||
|
[$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']]
|
||||||
|
);
|
||||||
|
if ($passed) {
|
||||||
|
Database::query(
|
||||||
|
"UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1,
|
||||||
|
retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at),
|
||||||
|
mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?",
|
||||||
|
[$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
$response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct,
|
||||||
|
'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isRetentionPhase) {
|
|
||||||
$response->json([
|
|
||||||
'status' => 'success',
|
|
||||||
'mastered' => true,
|
|
||||||
'is_retention_verified' => true,
|
|
||||||
'new_status' => 'mastered',
|
|
||||||
'message' => 'تم اجتياز اختبار الاسترجاع والتثبيت بنجاح تام! اعتُمد المفهوم كمتقن في الذاكرة طويلة المدى.',
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$response->json([
|
|
||||||
'status' => 'success',
|
|
||||||
'mastered' => false,
|
|
||||||
'is_retention_verified' => false,
|
|
||||||
'new_status' => 'in_remediation',
|
|
||||||
'message' => 'تم اجتياز الكويز العلاجي الأولي بنجاح! انتقل المفهوم إلى مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام ومنع النسيان).',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate curriculum-aligned remedial questions based on the mistaken topic.
|
|
||||||
*/
|
|
||||||
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'id' => $errorId * 10 + 1,
|
|
||||||
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
|
|
||||||
'options' => [
|
|
||||||
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
|
|
||||||
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
|
|
||||||
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
|
|
||||||
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
|
|
||||||
],
|
|
||||||
'correct_index' => 0,
|
|
||||||
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => $errorId * 10 + 2,
|
|
||||||
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
|
|
||||||
'options' => [
|
|
||||||
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
|
|
||||||
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
|
|
||||||
"تنخفض القيمة إلى الصفر فوراً",
|
|
||||||
"تنعكس الإشارة الرياضية للكمية القياسية"
|
|
||||||
],
|
|
||||||
'correct_index' => 1,
|
|
||||||
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => $errorId * 10 + 3,
|
|
||||||
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
|
|
||||||
'options' => [
|
|
||||||
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
|
|
||||||
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
|
|
||||||
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
|
|
||||||
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
|
|
||||||
],
|
|
||||||
'correct_index' => 0,
|
|
||||||
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function uuid(): string
|
private function uuid(): string
|
||||||
|
|||||||
@@ -121,13 +121,10 @@ class SuperAdminController
|
|||||||
{
|
{
|
||||||
$bundles = Database::select(
|
$bundles = Database::select(
|
||||||
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
|
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
|
||||||
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
|
cl.title AS lesson_title, cl.curriculum_version, cl.subject_key, cl.grade_key,
|
||||||
|
cl.semester_key, cl.unit_key, cl.lesson_key
|
||||||
FROM publication_bundles pb
|
FROM publication_bundles pb
|
||||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||||
JOIN curriculum_units cu ON cu.id = cl.unit_id
|
|
||||||
JOIN curriculum_courses cc ON cc.id = cu.course_id
|
|
||||||
JOIN subjects s ON s.id = cc.subject_id
|
|
||||||
JOIN grade_levels g ON g.id = cc.grade_level_id
|
|
||||||
ORDER BY pb.id DESC LIMIT 100"
|
ORDER BY pb.id DESC LIMIT 100"
|
||||||
);
|
);
|
||||||
$submissions = Database::select(
|
$submissions = Database::select(
|
||||||
@@ -150,35 +147,10 @@ class SuperAdminController
|
|||||||
|
|
||||||
public function reviewSubmission(Request $request, Response $response): void
|
public function reviewSubmission(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$body = $request->getBody();
|
$response->status(410)->json([
|
||||||
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
|
'status' => 'review_pipeline_required',
|
||||||
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
|
'message' => 'استخدم مسارات أدلة فحص الفيديو، قرار المراجع، ثم نشر مهمة المراجعة المعتمدة.',
|
||||||
|
]);
|
||||||
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
|
|
||||||
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$version = Database::selectOne(
|
|
||||||
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
|
|
||||||
[$versionUuid]
|
|
||||||
);
|
|
||||||
if (!$version) {
|
|
||||||
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $decision === 'approved' ? 'published' : 'rejected';
|
|
||||||
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
|
|
||||||
|
|
||||||
if ($decision === 'approved') {
|
|
||||||
Database::query(
|
|
||||||
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
|
|
||||||
[(int)$version['id'], (int)$version['teacher_submission_id']]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function count(string $table): int
|
private function count(string $table): int
|
||||||
|
|||||||
@@ -29,6 +29,50 @@ use App\Services\VideoReviewService;
|
|||||||
|
|
||||||
class VideoController
|
class VideoController
|
||||||
{
|
{
|
||||||
|
public function legacyPlaybackUnavailable(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$response->status(410)->json([
|
||||||
|
'status' => 'version_required',
|
||||||
|
'message' => 'استخدم نسخة الفيديو المنشورة المحددة بهوية الدرس المنهجي.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mayStreamVideo(Request $request, string $videoUuid): bool
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $videoUuid)) return false;
|
||||||
|
if ($request->role === 'super_admin') return true;
|
||||||
|
if ($request->role === 'teacher') {
|
||||||
|
$owner = Database::selectOne(
|
||||||
|
"SELECT l.id FROM lessons l JOIN courses c ON c.id=l.course_id
|
||||||
|
JOIN teachers t ON t.id=c.teacher_id
|
||||||
|
WHERE l.video_uuid=? AND t.identity_id=? LIMIT 1",
|
||||||
|
[$videoUuid, (int)$request->identity_id]
|
||||||
|
);
|
||||||
|
return (bool)$owner;
|
||||||
|
}
|
||||||
|
if ($request->role !== 'student') return false;
|
||||||
|
$row = Database::selectOne(
|
||||||
|
"SELECT l.id AS lesson_id,l.course_id,cl.grade_key
|
||||||
|
FROM lessons l JOIN video_versions vv ON vv.source_lesson_id=l.id AND vv.status='published'
|
||||||
|
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'
|
||||||
|
WHERE l.video_uuid=? AND l.encoding_status='ready' LIMIT 1",
|
||||||
|
[$videoUuid]
|
||||||
|
);
|
||||||
|
if (!$row) return false;
|
||||||
|
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||||
|
(int)$request->user_id, null,
|
||||||
|
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_key']),
|
||||||
|
(int)$row['course_id'], (int)$row['lesson_id'], false
|
||||||
|
);
|
||||||
|
return !empty($access['allowed']);
|
||||||
|
}
|
||||||
/** POST /api/video-versions/{versionId}/watch-sessions */
|
/** POST /api/video-versions/{versionId}/watch-sessions */
|
||||||
public function startWatchSession(Request $request, Response $response): void
|
public function startWatchSession(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
@@ -60,7 +104,12 @@ class VideoController
|
|||||||
cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id
|
cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id
|
||||||
FROM video_versions vv
|
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 teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published'
|
||||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
|
JOIN 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'
|
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||||
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
|
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
|
||||||
);
|
);
|
||||||
@@ -206,43 +255,74 @@ class VideoController
|
|||||||
try {
|
try {
|
||||||
$lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
|
$lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
|
||||||
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
|
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
|
||||||
$rows = Database::select(
|
// Scan in bounded batches: entitlement is per source lesson/course,
|
||||||
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
|
// so a SQL LIMIT before the access check would hide later teachers.
|
||||||
cl.grade_key, c.grade_level AS course_grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
|
$pdo = Database::getConnection();
|
||||||
COALESCE(pm.total_reviews_count, 0) AS rating_count
|
$ownsTransaction = !$pdo->inTransaction();
|
||||||
FROM teacher_submissions ts
|
if ($ownsTransaction) {
|
||||||
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
||||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
$pdo->beginTransaction();
|
||||||
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
|
|
||||||
JOIN courses c ON c.id = l.course_id
|
|
||||||
JOIN teachers t ON t.id = ts.teacher_id
|
|
||||||
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
|
|
||||||
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
|
|
||||||
ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101",
|
|
||||||
[$lesson['id']]
|
|
||||||
);
|
|
||||||
// A list is also protected content: do not disclose a teacher, count,
|
|
||||||
// or version that the student cannot play. A curriculum lesson may
|
|
||||||
// legitimately have versions attached to different courses.
|
|
||||||
$accessible = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
|
|
||||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
|
||||||
(int)$request->user_id,
|
|
||||||
$request->getHeader('x-national-id'),
|
|
||||||
$targetGrade,
|
|
||||||
(int)$row['course_id'],
|
|
||||||
(int)$row['source_lesson_id'],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
if (!empty($access['allowed'])) $accessible[] = $row;
|
|
||||||
}
|
}
|
||||||
// `cursor` is an offset in the stable rating order, rather than a
|
try {
|
||||||
// database id (an id seek would skip records after rating changes).
|
$batchSize = 100;
|
||||||
$page = array_slice($accessible, $cursor, $limit);
|
$rawOffset = 0;
|
||||||
$hasMore = count($accessible) > ($cursor + count($page));
|
$availableCount = 0;
|
||||||
$items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page);
|
$page = [];
|
||||||
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]);
|
do {
|
||||||
|
$rows = Database::select(
|
||||||
|
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
|
||||||
|
cl.grade_key, l.course_id, l.duration_seconds, vv.published_at,
|
||||||
|
t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
|
||||||
|
COALESCE(pm.total_reviews_count, 0) AS rating_count
|
||||||
|
FROM teacher_submissions ts
|
||||||
|
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||||
|
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||||
|
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
|
||||||
|
JOIN teachers t ON t.id = ts.teacher_id
|
||||||
|
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
|
||||||
|
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM video_review_jobs j
|
||||||
|
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'
|
||||||
|
WHERE j.video_version_id=vv.id AND j.status='approved'
|
||||||
|
)
|
||||||
|
ORDER BY (COALESCE(pm.total_reviews_count, 0) > 0) DESC,
|
||||||
|
COALESCE(pm.weighted_student_rating, 0) DESC, vv.id ASC
|
||||||
|
LIMIT {$batchSize} OFFSET {$rawOffset}",
|
||||||
|
[$lesson['id']]
|
||||||
|
);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
|
||||||
|
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||||
|
(int)$request->user_id,
|
||||||
|
$request->getHeader('x-national-id'),
|
||||||
|
$targetGrade,
|
||||||
|
(int)$row['course_id'],
|
||||||
|
(int)$row['source_lesson_id'],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (empty($access['allowed'])) continue;
|
||||||
|
if ($availableCount >= $cursor && count($page) < $limit) $page[] = $row;
|
||||||
|
$availableCount++;
|
||||||
|
}
|
||||||
|
$rawOffset += count($rows);
|
||||||
|
} while (count($rows) === $batchSize);
|
||||||
|
if ($ownsTransaction) $pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($ownsTransaction && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$items = array_map(static fn($r) => [
|
||||||
|
'video_version_id'=>$r['video_version_id'], 'submission_id'=>$r['submission_id'],
|
||||||
|
'teacher_name'=>$r['teacher_name'], 'rating'=>(float)$r['rating'],
|
||||||
|
'rating_count'=>(int)$r['rating_count'], 'is_new'=>(int)$r['rating_count']===0,
|
||||||
|
'duration_seconds'=>(int)$r['duration_seconds'], 'published_at'=>$r['published_at'],
|
||||||
|
], $page);
|
||||||
|
$nextCursor = $cursor + count($page);
|
||||||
|
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>$availableCount,'items'=>$items,'next_cursor'=>$nextCursor < $availableCount ? $nextCursor : null]]);
|
||||||
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
|
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
|
||||||
}
|
}
|
||||||
/** POST /api/teacher/submissions/preflight */
|
/** POST /api/teacher/submissions/preflight */
|
||||||
@@ -649,8 +729,8 @@ class VideoController
|
|||||||
public function streamLocalVideo(Request $request, Response $response): void
|
public function streamLocalVideo(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$uuid = $request->getParam('uuid');
|
$uuid = $request->getParam('uuid');
|
||||||
if (empty($uuid)) {
|
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
|
||||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']);
|
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,8 +746,8 @@ class VideoController
|
|||||||
$uuid = $request->getParam('uuid');
|
$uuid = $request->getParam('uuid');
|
||||||
$file = $request->getParam('file') ?: 'index.m3u8';
|
$file = $request->getParam('file') ?: 'index.m3u8';
|
||||||
|
|
||||||
if (empty($uuid)) {
|
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
|
||||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']);
|
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,13 +762,14 @@ class VideoController
|
|||||||
{
|
{
|
||||||
$body = $request->getBody();
|
$body = $request->getBody();
|
||||||
$lessonId = (int)($body['lesson_id'] ?? 0);
|
$lessonId = (int)($body['lesson_id'] ?? 0);
|
||||||
$timeSeconds = (int)($body['timestamp_seconds'] ?? 15);
|
$timeSeconds = (int)($body['timestamp_seconds'] ?? 0);
|
||||||
$rewindSecs = (int)($body['rewind_seconds'] ?? 45);
|
$rewindSecs = (int)($body['rewind_seconds'] ?? 0);
|
||||||
$question = trim((string)($body['question_text'] ?? ''));
|
$question = trim((string)($body['question_text'] ?? ''));
|
||||||
$options = (array)($body['options'] ?? []);
|
$options = (array)($body['options'] ?? []);
|
||||||
$correctIdx = (int)($body['correct_index'] ?? 0);
|
$correctIdx = (int)($body['correct_index'] ?? 0);
|
||||||
|
|
||||||
if (!$lessonId || empty($question) || empty($options)) {
|
if (!$lessonId || $question === '' || count($options) < 2 || $correctIdx < 0 || $correctIdx >= count($options) ||
|
||||||
|
$timeSeconds <= 0 || $rewindSecs < 0 || array_filter($options, fn($option) => !is_string($option) || trim($option) === '') !== []) {
|
||||||
$response->status(400)->json([
|
$response->status(400)->json([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
|
'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
|
||||||
@@ -696,11 +777,19 @@ class VideoController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
|
$lesson = Database::selectOne("SELECT l.id, l.course_id, l.duration_seconds, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
|
||||||
if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) {
|
if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) {
|
||||||
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']);
|
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($timeSeconds >= (int)$lesson['duration_seconds']) {
|
||||||
|
$response->status(422)->json(['status'=>'error','message'=>'توقيت السؤال خارج مدة الفيديو']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getConnection();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
|
||||||
$examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
$examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
@@ -712,7 +801,7 @@ class VideoController
|
|||||||
|
|
||||||
$examId = Database::insert(
|
$examId = Database::insert(
|
||||||
"INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
|
"INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
|
||||||
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)",
|
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'مسودة سؤال فيديو', ?, ?, 100.00, 10, 1, 0)",
|
||||||
[$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
|
[$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -736,10 +825,15 @@ class VideoController
|
|||||||
[$qId, $optText, $isCorrect]
|
[$qId, $optText, $isCorrect]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
$response->status(201)->json([
|
$response->status(201)->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!',
|
'message' => 'حُفظ السؤال كمسودة، ويحتاج ربطاً بدليل نسخة الفيديو ومراجعة قبل ظهوره للطالب.',
|
||||||
'data' => [
|
'data' => [
|
||||||
'exam_id' => $examId,
|
'exam_id' => $examId,
|
||||||
'timestamp_seconds' => $timeSeconds,
|
'timestamp_seconds' => $timeSeconds,
|
||||||
@@ -790,6 +884,23 @@ class VideoController
|
|||||||
$conditions[] = $gradeCond;
|
$conditions[] = $gradeCond;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do not expose legacy lesson rows as student video offerings. The
|
||||||
|
// exact published version must have approved, version-bound review
|
||||||
|
// evidence tied to the currently published curriculum Markdown.
|
||||||
|
$conditions[] = "EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
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'
|
||||||
|
WHERE vv.source_lesson_id=l.id AND vv.status='published'
|
||||||
|
)";
|
||||||
|
|
||||||
$whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : '';
|
$whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : '';
|
||||||
|
|
||||||
$lessons = Database::select(
|
$lessons = Database::select(
|
||||||
@@ -962,69 +1073,9 @@ class VideoController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-Healing Curriculum Guard: Purge any obsolete/mismatched calculus questions
|
// Legacy exams have no video-version evidence binding. They must not
|
||||||
// for non-calculus lessons (e.g. Grade 10 Systems of Equations)
|
// mutate storage during GET or be exposed as approved checkpoints.
|
||||||
$isCalculusLesson = (str_contains($lesson['title'], 'اشتقاق') || str_contains($lesson['title'], 'تفاضل'));
|
|
||||||
if (!$isCalculusLesson) {
|
|
||||||
try {
|
|
||||||
$mismatched = Database::selectOne(
|
|
||||||
"SELECT q.id FROM questions q
|
|
||||||
JOIN exams e ON q.exam_id = e.id
|
|
||||||
WHERE e.lesson_id = ? AND (q.question_text LIKE '%مشتق%' OR q.question_text LIKE '%f\'(x)%')
|
|
||||||
LIMIT 1",
|
|
||||||
[$lessonId]
|
|
||||||
);
|
|
||||||
if ($mismatched) {
|
|
||||||
$badExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
|
||||||
foreach ($badExams as $be) {
|
|
||||||
Database::query("DELETE FROM exams WHERE id = ?", [$be['id']]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
error_log("Curriculum self-healing notice: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playback is read-only. Checkpoints are published only by the review
|
|
||||||
// workflow after it verifies the video transcript and lesson Markdown.
|
|
||||||
|
|
||||||
// Fetch attached in-video Socratic Checkpoints with Questions and Options
|
|
||||||
$exams = Database::select(
|
|
||||||
"SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
|
|
||||||
FROM exams e
|
|
||||||
WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1
|
|
||||||
ORDER BY e.timestamp_seconds ASC",
|
|
||||||
[$lessonId]
|
|
||||||
);
|
|
||||||
|
|
||||||
$checkpoints = [];
|
$checkpoints = [];
|
||||||
foreach ($exams as $ex) {
|
|
||||||
$q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]);
|
|
||||||
if (!$q) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$opts = [];
|
|
||||||
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
|
||||||
if (count($opts) < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$checkpoints[] = [
|
|
||||||
'exam_id' => (int)$ex['exam_id'],
|
|
||||||
'question_id' => (int)$q['id'],
|
|
||||||
'timestamp_seconds' => (int)$ex['timestamp_seconds'],
|
|
||||||
'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
|
|
||||||
'question_text' => $q['question_text'],
|
|
||||||
'explanation' => $q['explanation_text'] ?? '',
|
|
||||||
'options' => array_map(function ($o) {
|
|
||||||
return [
|
|
||||||
'id' => (int)$o['id'],
|
|
||||||
'text' => $o['option_text'],
|
|
||||||
'is_correct' => (bool)$o['is_correct']
|
|
||||||
];
|
|
||||||
}, $opts)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
||||||
$playbackInfo = [];
|
$playbackInfo = [];
|
||||||
|
|||||||
@@ -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
|
public static function getCurriculumTree(): array
|
||||||
{
|
{
|
||||||
self::ensureStorage();
|
|
||||||
if (!file_exists(self::$manifestFile)) {
|
if (!file_exists(self::$manifestFile)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -281,13 +280,19 @@ class CurriculumService
|
|||||||
*/
|
*/
|
||||||
public static function saveLessonMarkdown(string $relativePath, string $content): bool
|
public static function saveLessonMarkdown(string $relativePath, string $content): bool
|
||||||
{
|
{
|
||||||
|
if (!self::safeMarkdownPath($relativePath)) return false;
|
||||||
self::ensureStorage();
|
self::ensureStorage();
|
||||||
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
$root = realpath(self::$storagePath);
|
||||||
|
if ($root === false) return false;
|
||||||
|
$fullPath = $root . '/' . $relativePath;
|
||||||
$dir = dirname($fullPath);
|
$dir = dirname($fullPath);
|
||||||
if (!is_dir($dir)) {
|
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
|
public static function getLessonMarkdown(string $relativePath): string
|
||||||
{
|
{
|
||||||
self::ensureStorage();
|
$root = realpath(self::$storagePath);
|
||||||
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
if ($root === false || !self::safeMarkdownPath($relativePath)) return '';
|
||||||
if (file_exists($fullPath)) {
|
$fullPath = realpath($root . '/' . $relativePath);
|
||||||
return file_get_contents($fullPath);
|
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
|
public static function saveLessonAiAssets(string $relativePath, array $assets): bool
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class NabehService
|
|||||||
|
|
||||||
// 1. Attempt with selected type (image card)
|
// 1. Attempt with selected type (image card)
|
||||||
$result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken);
|
$result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken);
|
||||||
if ($result['success']) {
|
if ($result['success'] || !empty($result['delivery_unknown'])) {
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,18 +192,18 @@ class NabehService
|
|||||||
if ($curlError) {
|
if ($curlError) {
|
||||||
error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError);
|
error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError);
|
||||||
|
|
||||||
// Resilient Fallback: If gateway response timed out after dispatching (duration >= 18s),
|
// A timeout does not prove whether Nabeh accepted or delivered the message.
|
||||||
// Nabeh has already received and queued the WhatsApp image/text message.
|
// Do not report success or send a second OTP via the text fallback: that could
|
||||||
// The OTP is already stored in Redis on Saqel.
|
// create a duplicate while the original gateway request is still completing.
|
||||||
if ($duration >= 18.0 && str_contains(strtolower($curlError), 'timed out')) {
|
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 [
|
return [
|
||||||
'success' => true,
|
'success' => false,
|
||||||
'type_used' => $type,
|
'delivery_unknown' => true,
|
||||||
'duration' => "{$duration}s",
|
'type_used' => $type,
|
||||||
'http_code' => 200,
|
'duration' => "{$duration}s",
|
||||||
'message' => 'تم إرسال رمز التحقق بنجاح عبر بطاقة الواتساب المصورة',
|
'http_code' => $httpCode,
|
||||||
'note' => 'Dispatched under gateway latency'
|
'error' => 'انتهت مهلة بوابة واتساب؛ لم يصل تأكيد التسليم. تحقّق من الرسائل قبل طلب رمز جديد.'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,31 +51,24 @@ final class PublishedContentService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = [
|
// Published storage keys must resolve to their exact file inside the
|
||||||
$root . '/' . $storageKey,
|
// curriculum root. Do not fall back to another similarly named file.
|
||||||
$root . '/_incoming/' . $storageKey,
|
$real = realpath($root . '/' . $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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
$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) {
|
public static function studentMayRead(int $studentId, array $asset): bool
|
||||||
$real = realpath($candidate);
|
{
|
||||||
if ($real && is_file($real)) {
|
if ($studentId <= 0 || empty($asset['grade_key'])) return false;
|
||||||
// Ensure candidate is strictly within the project root to prevent traversal
|
$student = Database::selectOne(
|
||||||
if ($projectRoot !== '' && str_starts_with($real, $projectRoot)) {
|
"SELECT s.grade_level FROM students s
|
||||||
return $real;
|
JOIN auth_identities ai ON ai.id=s.identity_id AND ai.status='active'
|
||||||
}
|
WHERE s.id=? LIMIT 1",
|
||||||
}
|
[$studentId]
|
||||||
}
|
);
|
||||||
return null;
|
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,
|
string $targetGrade,
|
||||||
?int $courseId = null,
|
?int $courseId = null,
|
||||||
?int $lessonId = null,
|
?int $lessonId = null,
|
||||||
bool $allowSideEffects = true
|
bool $allowSideEffects = false
|
||||||
): array {
|
): array {
|
||||||
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
||||||
$student = null;
|
$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 (!$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 [
|
return [
|
||||||
'allowed' => false,
|
'allowed' => false,
|
||||||
'reason' => 'unauthenticated_or_not_found',
|
'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);
|
$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 ($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 [
|
return [
|
||||||
'allowed' => true,
|
'allowed' => false, 'reason' => 'grade_mismatch',
|
||||||
'reason' => 'school_sponsored_free',
|
'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.',
|
||||||
'message' => 'مشمول مجاناً عبر المنظومة المؤسسية الشريكة (مديرية الثقافة العسكرية / المدرسة المعتمدة).',
|
'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade,
|
||||||
'student_grade' => $activeStudentGrade,
|
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false),
|
||||||
'is_sponsored' => true,
|
|
||||||
'fee_jod' => 0.00
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (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) {
|
if ($courseId) {
|
||||||
// المساقات المجانية (السعر 0.00 دينار) متاحة فوراً ومجاناً للجميع
|
$course = Database::selectOne(
|
||||||
$course = Database::selectOne("SELECT price_jod FROM courses WHERE id = ? LIMIT 1", [$courseId]);
|
'SELECT c.id, c.school_id, c.is_school_exclusive, c.is_published, c.price_jod,
|
||||||
if ($course && (float)($course['price_jod'] ?? 0) <= 0.0) {
|
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 [
|
return [
|
||||||
'allowed' => true,
|
'allowed' => true, 'reason' => 'school_course_included',
|
||||||
'reason' => 'free_course',
|
'student_grade' => $activeStudentGrade, 'is_sponsored' => true,
|
||||||
'student_grade' => $activeStudentGrade,
|
|
||||||
'is_sponsored' => false
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
$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
|
||||||
|
AND pass_type IN ('discounted_micro_pass','full_marketplace')
|
||||||
AND (expires_at IS NULL OR expires_at > NOW())
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[(int)$student['id'], $courseId]
|
[(int)$student['id'], $courseId]
|
||||||
@@ -174,7 +156,7 @@ class StudentAccessControlService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// فحص إذا كان الدرس معاينة مجانية
|
// An explicit free preview is allowed only on a public course.
|
||||||
if ($lessonId) {
|
if ($lessonId) {
|
||||||
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||||
if ($lesson && !empty($lesson['is_free_preview'])) {
|
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
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
|
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
|
||||||
final class TeacherLedgerService {
|
final class TeacherLedgerService
|
||||||
public static function balance(int $teacherId): array {
|
{
|
||||||
$account=self::account($teacherId);
|
public static function balance(int $teacherId): array
|
||||||
$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]);
|
// Balance reads must never create accounts or mutate financial state.
|
||||||
$gross=(int)($row['balance']??0);$held=(int)($holds['held']??0);
|
$accountId = self::existingAccountId($teacherId);
|
||||||
return ['account_id'=>$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)];
|
$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'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
|
public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array
|
||||||
$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 ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000
|
||||||
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]);}
|
|| !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) {
|
||||||
$balance=self::balance($teacherId);
|
return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
|
||||||
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]);
|
$pdo = Database::getConnection();
|
||||||
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
$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'=>'بيانات إلغاء الحجز غير صالحة.'];
|
public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array
|
||||||
$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'=>'الحجز غير موجود أو تمت تسويته.'];
|
$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]);
|
private static function existingAccountId(int $teacherId): ?int
|
||||||
if($existing)return(int)$existing['id'];
|
{
|
||||||
Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]);
|
$row = Database::selectOne(
|
||||||
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);
|
"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']]);
|
$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,['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) 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 ($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']]); }
|
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;
|
$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 {
|
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('هوية النسخة أو بصمة الملف غير صالحة.');
|
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 {
|
public static function reserveUpload(int $teacherId, string $versionUuid): bool {
|
||||||
if (!self::uuidValid($versionUuid)) return false;
|
if (!self::uuidValid($versionUuid)) return false;
|
||||||
return Database::execute(
|
return Database::execute(
|
||||||
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
"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')",
|
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status IN ('draft', 'uploading')",
|
||||||
[$versionUuid, $teacherId]
|
[$versionUuid, $teacherId]
|
||||||
) >= 1;
|
) >= 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();
|
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||||
try {
|
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) 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(($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'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
|
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';
|
$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("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']]);
|
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)) {
|
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
|
||||||
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
|
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();
|
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||||
try {
|
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 (!$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'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
|
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]);
|
$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]);
|
$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'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
|
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
|
||||||
}
|
}
|
||||||
$segments=$coverage['segments']; $lastEnd=0;
|
if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
|
||||||
foreach ($segments as $segment) {
|
!hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) {
|
||||||
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'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']);
|
||||||
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
|
|
||||||
}
|
|
||||||
$lastEnd=(float)$segment['end_seconds'];
|
|
||||||
}
|
}
|
||||||
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
|
Database::query('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']]);
|
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();
|
$pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$job = Database::selectOne(
|
$job = Database::selectOne(
|
||||||
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
|
"SELECT j.id, j.status, j.video_version_id, j.video_sha256, j.markdown_sha256,
|
||||||
vv.source_lesson_id, ts.id AS submission_id,
|
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
|
currentv.uuid AS current_version_uuid
|
||||||
FROM video_review_jobs j
|
FROM video_review_jobs j
|
||||||
JOIN video_versions vv ON vv.id = j.video_version_id
|
JOIN video_versions vv ON vv.id = j.video_version_id
|
||||||
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_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
|
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
|
||||||
WHERE j.uuid = ? FOR UPDATE",
|
WHERE j.uuid = ? FOR UPDATE",
|
||||||
[$jobUuid]
|
[$jobUuid]
|
||||||
);
|
);
|
||||||
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
|
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']]);
|
$report = Database::selectOne('SELECT human_decision,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
|
||||||
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
|
$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' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
|
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'] ?? ''))) {
|
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
|
||||||
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
|
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 = '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 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']]);
|
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('Content-Type: ' . $mime);
|
||||||
header('Cache-Control: public, max-age=86400');
|
header('Cache-Control: private, no-store');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Content-Length: ' . filesize($filePath));
|
header('Content-Length: ' . filesize($filePath));
|
||||||
readfile($filePath);
|
readfile($filePath);
|
||||||
@@ -438,7 +438,7 @@ class VideoService
|
|||||||
|
|
||||||
header('Content-Type: ' . $mime);
|
header('Content-Type: ' . $mime);
|
||||||
header('Accept-Ranges: bytes');
|
header('Accept-Ranges: bytes');
|
||||||
header('Cache-Control: public, max-age=3600');
|
header('Cache-Control: private, no-store');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
header('Access-Control-Allow-Origin: *');
|
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;
|
if (!$row['allowed']) return $row;
|
||||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||||
try {
|
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']]);
|
$student=Database::selectOne('SELECT id FROM students WHERE id=? FOR UPDATE',[$studentId]);
|
||||||
if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; }
|
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();
|
$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']]);
|
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);
|
$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 {
|
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'=>'حدث المشاهدة غير صالح.'];
|
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 {
|
$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)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']);
|
||||||
if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','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']]);
|
$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'] >= $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'=>'تسلسل أحداث المشاهدة غير متصل.']);
|
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)]);
|
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;
|
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
|
||||||
if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) {
|
$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']);
|
||||||
$clientDelta=$positionMs-(int)$last['client_position_ms'];
|
$eligible=(int)$decision['seconds'];
|
||||||
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
|
if($type==='heartbeat') Database::insert(
|
||||||
// A contiguous interval is bounded by elapsed server time and 60s;
|
"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',?)",
|
||||||
// seeks, background bursts, and client-only minutes earn nothing.
|
[$session['id'],$sequence,(int)$last['client_position_ms'],$positionMs,$eligible,$decision['reason'],$decision['status']]
|
||||||
if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) {
|
);
|
||||||
$eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60);
|
|
||||||
if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);}
|
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]);
|
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;}
|
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||||
}
|
}
|
||||||
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
|
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
|
||||||
if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.'];
|
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'=>'نسخة الفيديو غير منشورة.'];
|
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']??'غير مصرح بالمشاهدة.'];
|
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')];
|
return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Apply after database_schema.sql. Rollback: disable the new endpoints, preserve
|
||||||
|
-- attempt rows for audit, then remove the column/table only after export.
|
||||||
|
ALTER TABLE student_error_notebook
|
||||||
|
ADD COLUMN retention_due_at TIMESTAMP NULL DEFAULT NULL AFTER mastered_at;
|
||||||
|
|
||||||
|
-- Existing question rows remain draft. A reviewer must approve each question
|
||||||
|
-- against the named lesson and record its source before it may prove remediation.
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN remediation_review_status ENUM('draft','approved','rejected') NOT NULL DEFAULT 'draft',
|
||||||
|
ADD COLUMN remediation_reviewed_by BIGINT UNSIGNED NULL,
|
||||||
|
ADD COLUMN remediation_reviewed_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
ADD COLUMN remediation_source_reference VARCHAR(700) NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS error_notebook_quiz_attempts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
uuid CHAR(36) NOT NULL,
|
||||||
|
error_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
student_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
phase ENUM('initial','retention') NOT NULL,
|
||||||
|
question_ids_json JSON NOT NULL,
|
||||||
|
answer_key_json JSON NOT NULL,
|
||||||
|
status ENUM('open','passed','failed') NOT NULL DEFAULT 'open',
|
||||||
|
answers_sha256 CHAR(64) NULL,
|
||||||
|
correct_count TINYINT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
submitted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_error_quiz_uuid (uuid),
|
||||||
|
KEY idx_error_quiz_student (student_id,error_id,status),
|
||||||
|
CONSTRAINT fk_error_quiz_error FOREIGN KEY (error_id) REFERENCES student_error_notebook(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_error_quiz_student FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Apply after 20260909_financial_ledger.sql, before deploying releaseHold changes.
|
||||||
|
-- Existing released rows stay NULL: their original reason cannot be reconstructed.
|
||||||
|
ALTER TABLE teacher_withdrawal_holds
|
||||||
|
ADD COLUMN release_reason VARCHAR(500) NULL AFTER released_at;
|
||||||
|
|
||||||
|
-- Rollback: revert the application writer first. Keep this nullable audit column
|
||||||
|
-- and any recorded reasons; dropping it would erase financial history.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Apply after 20260909_watch_sessions.sql.
|
||||||
|
-- Preflight: this intentionally fails if historical data has more than one
|
||||||
|
-- active session per student. Reconcile those rows to ended/invalidated first.
|
||||||
|
ALTER TABLE watch_sessions
|
||||||
|
ADD COLUMN active_student_id BIGINT UNSIGNED
|
||||||
|
GENERATED ALWAYS AS (CASE WHEN status = 'active' THEN student_id ELSE NULL END) STORED,
|
||||||
|
ADD UNIQUE KEY uq_watch_one_active_per_student (active_student_id);
|
||||||
|
|
||||||
|
-- One decision per event, including repeated positions that are legitimately
|
||||||
|
-- excluded. Position pairs alone are not an event identity.
|
||||||
|
ALTER TABLE eligible_watch_intervals
|
||||||
|
DROP INDEX uq_eligible_interval,
|
||||||
|
ADD COLUMN event_sequence_no INT UNSIGNED NULL AFTER watch_session_id,
|
||||||
|
ADD UNIQUE KEY uq_watch_interval_event (watch_session_id, event_sequence_no, policy_version);
|
||||||
|
|
||||||
|
-- Rollback: stop writers that rely on the invariant first. Dropping the key
|
||||||
|
-- weakens protection and is not a routine production rollback.
|
||||||
@@ -75,8 +75,9 @@ $chatMiddleware = [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\Chat
|
|||||||
$router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumController::class, 'uploadPdf'], $curriculumManagerMiddleware);
|
$router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumController::class, 'uploadPdf'], $curriculumManagerMiddleware);
|
||||||
$router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
|
$router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
|
||||||
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware);
|
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware);
|
||||||
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']);
|
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getPublishedTree']);
|
||||||
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/teacher/curriculum/intake-tree', [\App\Controllers\CurriculumController::class, 'getTree'], $teacherMiddleware);
|
||||||
|
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware);
|
||||||
$router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware);
|
$router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware);
|
||||||
$router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
|
$router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
|
||||||
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware);
|
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware);
|
||||||
@@ -91,8 +92,10 @@ $router->get('/api/curriculum/search', function ($request, $response) {
|
|||||||
});
|
});
|
||||||
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
|
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
|
||||||
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
|
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
|
||||||
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware);
|
||||||
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
|
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
|
||||||
|
$router->post('/api/curriculum/assets/{assetId}/download-ticket', [\App\Controllers\CurriculumController::class, 'issueAssetDownloadTicket'], $studentMiddleware);
|
||||||
|
$router->get('/api/curriculum/assets/{assetId}/download', [\App\Controllers\CurriculumController::class, 'downloadAssetWithTicket']);
|
||||||
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
|
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
|
||||||
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
|
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
|
||||||
$router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware);
|
$router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware);
|
||||||
@@ -144,9 +147,9 @@ $router->post('/api/auth/otp/verify', [\App\Controllers\AuthController::class,
|
|||||||
$router->post('/api/auth/logout', [\App\Controllers\AuthController::class, 'logout'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->post('/api/auth/logout', [\App\Controllers\AuthController::class, 'logout'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::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'], $studentMiddleware);
|
||||||
$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]);
|
$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], $studentMiddleware);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -175,8 +178,8 @@ $router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoCon
|
|||||||
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
|
|
||||||
// Student & Teacher Chat Routes (API-Driven, Authenticated)
|
// Student & Teacher Chat Routes (API-Driven, Authenticated)
|
||||||
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], $chatMiddleware);
|
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], $chatMiddleware);
|
||||||
@@ -195,7 +198,7 @@ $router->post('/api/student/lessons/{id}/progress', [\App\Controllers\VideoContr
|
|||||||
// Smart Error Notebook & Adaptive Remediation Routes (دفتر الأخطاء الذكي والمسارات العلاجية)
|
// Smart Error Notebook & Adaptive Remediation Routes (دفتر الأخطاء الذكي والمسارات العلاجية)
|
||||||
$router->get('/api/student/error-notebook', [\App\Controllers\ErrorNotebookController::class, 'getErrorNotebook'], $studentMiddleware);
|
$router->get('/api/student/error-notebook', [\App\Controllers\ErrorNotebookController::class, 'getErrorNotebook'], $studentMiddleware);
|
||||||
$router->post('/api/student/error-notebook/log', [\App\Controllers\ErrorNotebookController::class, 'logError'], $studentMiddleware);
|
$router->post('/api/student/error-notebook/log', [\App\Controllers\ErrorNotebookController::class, 'logError'], $studentMiddleware);
|
||||||
$router->get('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware);
|
$router->post('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware);
|
||||||
$router->post('/api/student/error-notebook/resolve', [\App\Controllers\ErrorNotebookController::class, 'resolveError'], $studentMiddleware);
|
$router->post('/api/student/error-notebook/resolve', [\App\Controllers\ErrorNotebookController::class, 'resolveError'], $studentMiddleware);
|
||||||
|
|
||||||
// Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense)
|
// Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense)
|
||||||
|
|||||||
@@ -3,169 +3,72 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Links existing and ministry Grade 10 Math videos to teacher_submissions and video_versions
|
* Read-only audit for historic Grade 10 Math video records.
|
||||||
* so that they appear in the curriculum tree with has_video = true and stream in the student app.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* This replaces the retired linker, which hard-coded a Bunny hostname and
|
||||||
* php backend/scripts/link_legacy_math_videos.php [--apply]
|
* marked legacy records published without version-bound video review evidence.
|
||||||
|
* New media must go through TeacherSubmissionService and the full review flow.
|
||||||
|
*
|
||||||
|
* Usage: php backend/scripts/link_legacy_math_videos.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Services\CurriculumService;
|
|
||||||
|
|
||||||
$apply = in_array('--apply', $argv, true);
|
if (in_array('--apply', $argv, true)) {
|
||||||
|
fwrite(STDERR, "This legacy linker is retired and read-only. Use the teacher upload and review workflow.\n");
|
||||||
echo "=== Grade 10 Math Video Linking & Activation ===\n";
|
exit(2);
|
||||||
|
|
||||||
// 1. Resolve master system course
|
|
||||||
$courseId = CurriculumService::getOrCreateSystemCourse();
|
|
||||||
echo "System Course ID: {$courseId}\n";
|
|
||||||
|
|
||||||
// 2. Resolve teacher ID
|
|
||||||
$teacher = Database::selectOne("SELECT id, full_name FROM teachers WHERE id = 1 LIMIT 1");
|
|
||||||
if (!$teacher) {
|
|
||||||
$teacher = Database::selectOne("SELECT id, full_name FROM teachers LIMIT 1");
|
|
||||||
}
|
}
|
||||||
if (!$teacher) {
|
|
||||||
echo "ERROR: No teacher found in database.\n";
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
$teacherId = (int)$teacher['id'];
|
|
||||||
echo "Publishing Teacher: {$teacher['full_name']} (ID: {$teacherId})\n";
|
|
||||||
|
|
||||||
// 3. Find Grade 10 Math Unit 1 Lessons in curriculum_lessons
|
echo "=== Grade 10 Math legacy video audit (read-only) ===\n";
|
||||||
$mathLessons = Database::select(
|
|
||||||
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path
|
$lessons = Database::select(
|
||||||
FROM curriculum_lessons
|
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path, curriculum_version
|
||||||
WHERE grade_key = 'grade_10' AND subject_key = 'math_10' AND unit_key = 'unit_01'
|
FROM curriculum_lessons
|
||||||
ORDER BY lesson_key"
|
WHERE grade_key='grade_10' AND subject_key='math_10' AND unit_key='unit_01'
|
||||||
|
ORDER BY lesson_key, id"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (empty($mathLessons)) {
|
foreach ($lessons as $curriculumLesson) {
|
||||||
echo "No Grade 10 Math Unit 1 lessons found in curriculum_lessons.\n";
|
$path = (string)$curriculumLesson['source_manifest_path'];
|
||||||
exit(1);
|
$pathWithoutExtension = preg_replace('/\.md$/i', '', $path);
|
||||||
}
|
$records = Database::select(
|
||||||
|
"SELECT l.id, l.title, l.curriculum_key, l.storage_type, l.duration_seconds,
|
||||||
|
l.encoding_status, l.hls_url, l.r2_url,
|
||||||
|
vv.uuid AS version_uuid, vv.status AS version_status,
|
||||||
|
ts.status AS submission_status, j.status AS review_status
|
||||||
|
FROM lessons l
|
||||||
|
LEFT JOIN video_versions vv ON vv.source_lesson_id=l.id
|
||||||
|
LEFT JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
||||||
|
LEFT JOIN video_review_jobs j ON j.video_version_id=vv.id
|
||||||
|
WHERE l.curriculum_key IN (?, ?)
|
||||||
|
ORDER BY l.id, vv.id",
|
||||||
|
[$path, $pathWithoutExtension]
|
||||||
|
);
|
||||||
|
|
||||||
echo "Found " . count($mathLessons) . " Unit 1 Math lessons in curriculum_lessons.\n";
|
echo "\n{$path} [{$curriculumLesson['curriculum_version']}]\n";
|
||||||
|
if ($records === []) {
|
||||||
// Ministry / Certified lesson video streams (Bunny Stream / Cloudflare R2 standard HLS)
|
echo " no matching video records\n";
|
||||||
$knownStreams = [
|
|
||||||
'lesson_01' => [
|
|
||||||
'title' => 'الدرس الأول: حل نظام مكون من معادلة خطية ومعادلة تربيعية',
|
|
||||||
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l1/playlist.m3u8',
|
|
||||||
'duration' => 1380, // 23 minutes
|
|
||||||
],
|
|
||||||
'lesson_02' => [
|
|
||||||
'title' => 'الدرس الثاني: حل نظام مكون من معادلتين تربيعيتين',
|
|
||||||
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l2/playlist.m3u8',
|
|
||||||
'duration' => 1440, // 24 minutes
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($mathLessons as $cl) {
|
|
||||||
$lessonKey = $cl['lesson_key'];
|
|
||||||
if (!isset($knownStreams[$lessonKey])) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$streamInfo = $knownStreams[$lessonKey];
|
foreach ($records as $record) {
|
||||||
echo "\nProcessing: [{$lessonKey}] {$cl['title']}\n";
|
$url = (string)($record['hls_url'] ?: $record['r2_url'] ?: '');
|
||||||
|
$host = $url !== '' ? (string)(parse_url($url, PHP_URL_HOST) ?: 'relative') : 'none';
|
||||||
// Check if a record already exists in lessons table
|
$storage = str_ends_with($host, '.r2.dev') ? 'cloudflare_r2' : $record['storage_type'];
|
||||||
$existingLesson = Database::selectOne(
|
echo sprintf(
|
||||||
"SELECT id, title, hls_url, encoding_status FROM lessons WHERE curriculum_key = ? OR title LIKE ? LIMIT 1",
|
" lesson=%d storage=%s duration=%ss encoding=%s submission=%s version=%s review=%s host=%s\n",
|
||||||
[$cl['source_manifest_path'], '%' . $streamInfo['title'] . '%']
|
(int)$record['id'],
|
||||||
);
|
$storage,
|
||||||
|
(int)$record['duration_seconds'],
|
||||||
$lessonId = 0;
|
(string)$record['encoding_status'],
|
||||||
if ($existingLesson) {
|
(string)($record['submission_status'] ?? 'none'),
|
||||||
$lessonId = (int)$existingLesson['id'];
|
(string)($record['version_status'] ?? 'none'),
|
||||||
echo " - Existing lesson in `lessons` table found: ID {$lessonId}\n";
|
(string)($record['review_status'] ?? 'missing'),
|
||||||
if ($apply && empty($existingLesson['hls_url'])) {
|
$host
|
||||||
Database::query(
|
|
||||||
"UPDATE lessons SET hls_url = ?, encoding_status = 'ready', duration_seconds = ? WHERE id = ?",
|
|
||||||
[$streamInfo['hls_url'], $streamInfo['duration'], $lessonId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo " - Creating entry in `lessons` table...\n";
|
|
||||||
if ($apply) {
|
|
||||||
$vUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
||||||
$lessonId = (int)Database::insert(
|
|
||||||
"INSERT INTO lessons (course_id, title, curriculum_key, sequence_order, video_uuid, storage_type, hls_url, encoding_status, duration_seconds)
|
|
||||||
VALUES (?, ?, ?, 1, ?, 'bunny_stream', ?, 'ready', ?)",
|
|
||||||
[$courseId, $cl['title'], $cl['source_manifest_path'], $vUuid, $streamInfo['hls_url'], $streamInfo['duration']]
|
|
||||||
);
|
|
||||||
echo " - Created lesson ID: {$lessonId}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check teacher_submissions
|
|
||||||
$sub = Database::selectOne(
|
|
||||||
"SELECT id, uuid, current_published_video_version_id, status FROM teacher_submissions WHERE teacher_id = ? AND curriculum_lesson_id = ? LIMIT 1",
|
|
||||||
[$teacherId, $cl['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
$subId = 0;
|
|
||||||
if ($sub) {
|
|
||||||
$subId = (int)$sub['id'];
|
|
||||||
echo " - Existing submission found: ID {$subId} (Status: {$sub['status']})\n";
|
|
||||||
if ($apply && $sub['status'] !== 'published') {
|
|
||||||
Database::query("UPDATE teacher_submissions SET status = 'published' WHERE id = ?", [$subId]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo " - Creating teacher_submission...\n";
|
|
||||||
if ($apply) {
|
|
||||||
$sUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
||||||
$subId = (int)Database::insert(
|
|
||||||
"INSERT INTO teacher_submissions (uuid, teacher_id, curriculum_lesson_id, status) VALUES (?, ?, ?, 'published')",
|
|
||||||
[$sUuid, $teacherId, $cl['id']]
|
|
||||||
);
|
|
||||||
echo " - Created submission ID: {$subId}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check video_versions
|
|
||||||
if ($subId > 0 && $lessonId > 0) {
|
|
||||||
$vv = Database::selectOne(
|
|
||||||
"SELECT id, uuid, status FROM video_versions WHERE teacher_submission_id = ? AND source_lesson_id = ? LIMIT 1",
|
|
||||||
[$subId, $lessonId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$vvId = 0;
|
|
||||||
if ($vv) {
|
|
||||||
$vvId = (int)$vv['id'];
|
|
||||||
echo " - Existing video_version found: ID {$vvId} (Status: {$vv['status']})\n";
|
|
||||||
if ($apply && $vv['status'] !== 'published') {
|
|
||||||
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$vvId]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo " - Creating video_version...\n";
|
|
||||||
if ($apply) {
|
|
||||||
$vvUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
||||||
$vvId = (int)Database::insert(
|
|
||||||
"INSERT INTO video_versions (uuid, teacher_submission_id, version_number, status, source_lesson_id, published_at)
|
|
||||||
VALUES (?, ?, 1, 'published', ?, NOW())",
|
|
||||||
[$vvUuid, $subId, $lessonId]
|
|
||||||
);
|
|
||||||
echo " - Created video_version ID: {$vvId}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($apply && $vvId > 0) {
|
|
||||||
Database::query(
|
|
||||||
"UPDATE teacher_submissions SET current_published_video_version_id = ? WHERE id = ?",
|
|
||||||
[$vvId, $subId]
|
|
||||||
);
|
|
||||||
echo " - Updated submission current_published_video_version_id = {$vvId}\n";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($apply) {
|
echo "\nNo records were changed.\n";
|
||||||
echo "\n=== Successfully linked and activated Math Unit 1 videos! ===\n";
|
|
||||||
} else {
|
|
||||||
echo "\nDRY RUN complete. Run with --apply to execute writes.\n";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Publication manager for Grade 10 curriculum bundles and assets.
|
* Publication manager for Grade 10 curriculum bundles and assets.
|
||||||
*
|
*
|
||||||
* Transitions lessons and their assets from `draft`/`unverified` to `published`/`approved`.
|
* Publishes bundles only after independent lesson and asset approval.
|
||||||
*
|
*
|
||||||
* Safety rules:
|
* Safety rules:
|
||||||
* - Default mode is a read-only dry run. It writes nothing unless `--apply` is present.
|
* - Default mode is a read-only dry run. It writes nothing unless `--apply` is present.
|
||||||
* - All database writes happen inside a single atomic transaction.
|
* - All database writes happen inside a single atomic transaction.
|
||||||
* - Only assets belonging to the selected curriculum lessons are updated.
|
* - This script never grants academic approval or clears rights.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* php backend/scripts/publish_grade10_bundle.php --status
|
* php backend/scripts/publish_grade10_bundle.php --status
|
||||||
@@ -93,7 +93,7 @@ if ($isPilot) {
|
|||||||
'grade_10/math_10/semester_1/unit_01/lesson_01.md',
|
'grade_10/math_10/semester_1/unit_01/lesson_01.md',
|
||||||
'grade_10/math_10/semester_1/unit_01/lesson_02.md',
|
'grade_10/math_10/semester_1/unit_01/lesson_02.md',
|
||||||
'grade_10/math_10/semester_1/unit_01/lesson_03.md',
|
'grade_10/math_10/semester_1/unit_01/lesson_03.md',
|
||||||
'grade_10/math_10/semester_1/unit_01/intro_and_project.md',
|
'grade_10/physics_10/semester_1/unit_02/lesson_03.md',
|
||||||
// Physics 10: Semester 1, Units 1 & 2
|
// Physics 10: Semester 1, Units 1 & 2
|
||||||
'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
|
'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
|
||||||
'grade_10/physics_10/semester_1/unit_01/lesson_02.md',
|
'grade_10/physics_10/semester_1/unit_01/lesson_02.md',
|
||||||
@@ -140,12 +140,16 @@ if (empty($targetLessons)) {
|
|||||||
echo "No matching curriculum lessons found for the specified criteria.\n";
|
echo "No matching curriculum lessons found for the specified criteria.\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
if ($isPilot && count($targetLessons) !== 12) {
|
||||||
|
fwrite(STDERR, "REFUSE: pilot must contain exactly 12 matching lessons; found " . count($targetLessons) . ".\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
$lessonIds = array_column($targetLessons, 'id');
|
$lessonIds = array_column($targetLessons, 'id');
|
||||||
$idList = implode(',', array_map('intval', $lessonIds));
|
$idList = implode(',', array_map('intval', $lessonIds));
|
||||||
|
|
||||||
// 3. Plan summary
|
// 3. Plan summary
|
||||||
$targetAction = $isRevert ? 'REVERT TO DRAFT' : 'PUBLISH / APPROVE';
|
$targetAction = $isRevert ? 'WITHDRAW PUBLISHED BUNDLES' : 'PUBLISH REVIEWED BUNDLES';
|
||||||
echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN');
|
echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN');
|
||||||
echo sprintf("Action: %s\n", $targetAction);
|
echo sprintf("Action: %s\n", $targetAction);
|
||||||
echo sprintf("Matched Lessons: %d\n", count($targetLessons));
|
echo sprintf("Matched Lessons: %d\n", count($targetLessons));
|
||||||
@@ -161,8 +165,60 @@ foreach ($targetLessons as $idx => $l) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publicationBlockers(array $targetLessons, bool $lock): array
|
||||||
|
{
|
||||||
|
$blockers = [];
|
||||||
|
foreach ($targetLessons as $lesson) {
|
||||||
|
$id = (int)$lesson['id'];
|
||||||
|
$path = $lesson['source_manifest_path'];
|
||||||
|
$detail = Database::selectOne(
|
||||||
|
'SELECT source_status, reviewed_by, reviewed_at FROM curriculum_lessons WHERE id = ?' . ($lock ? ' FOR UPDATE' : ''),
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
if (!$detail || $detail['source_status'] !== 'approved' || empty($detail['reviewed_by']) || empty($detail['reviewed_at'])) {
|
||||||
|
$blockers[] = "{$path}: academic approval with reviewer and date is missing";
|
||||||
|
}
|
||||||
|
$bundles = Database::select(
|
||||||
|
'SELECT id, status FROM publication_bundles WHERE curriculum_lesson_id = ?' . ($lock ? ' FOR UPDATE' : ''),
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
if (count($bundles) !== 1) {
|
||||||
|
$blockers[] = "{$path}: expected exactly one bundle; found " . count($bundles);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bundle = $bundles[0];
|
||||||
|
if (!in_array($bundle['status'], ['review', 'published'], true)) {
|
||||||
|
$blockers[] = "{$path}: bundle must be in review before publication";
|
||||||
|
}
|
||||||
|
$assets = Database::select(
|
||||||
|
'SELECT a.id, a.asset_type, a.review_status, a.rights_status, a.source_reference, pba.role
|
||||||
|
FROM publication_bundle_assets pba JOIN content_assets a ON a.id = pba.content_asset_id
|
||||||
|
WHERE pba.publication_bundle_id = ?' . ($lock ? ' FOR UPDATE' : ''),
|
||||||
|
[(int)$bundle['id']]
|
||||||
|
);
|
||||||
|
if ($assets === []) {
|
||||||
|
$blockers[] = "{$path}: bundle has no assets";
|
||||||
|
}
|
||||||
|
$hasPrimary = false;
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
if ($asset['role'] === 'primary_lesson' && $asset['asset_type'] === 'lesson_markdown') $hasPrimary = true;
|
||||||
|
if ($asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || trim((string)$asset['source_reference']) === '') {
|
||||||
|
$blockers[] = "{$path}: asset {$asset['id']} lacks review, rights clearance, or source";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasPrimary) $blockers[] = "{$path}: approved lesson Markdown is missing";
|
||||||
|
}
|
||||||
|
return $blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$apply && !$isRevert) {
|
||||||
|
$blockers = publicationBlockers($targetLessons, false);
|
||||||
|
foreach ($blockers as $blocker) echo "BLOCKED {$blocker}\n";
|
||||||
|
echo $blockers === [] ? "\n[Ready for reviewed publication; append --apply.]\n" : "\n[Publication blocked; resolve review evidence first.]\n";
|
||||||
|
exit($blockers === [] ? 0 : 2);
|
||||||
|
}
|
||||||
if (!$apply) {
|
if (!$apply) {
|
||||||
echo "\n[Dry-run completed. To execute, append --apply to your command.]\n";
|
echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,34 +228,17 @@ $pdo->beginTransaction();
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if ($isRevert) {
|
if ($isRevert) {
|
||||||
// Revert to draft
|
// Withdrawal preserves academic decisions, rights records and history.
|
||||||
Database::query(
|
Database::query(
|
||||||
"UPDATE curriculum_lessons SET source_status = 'unverified' WHERE id IN ({$idList})"
|
"UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'"
|
||||||
);
|
|
||||||
Database::query(
|
|
||||||
"UPDATE publication_bundles SET status = 'draft', published_at = NULL WHERE curriculum_lesson_id IN ({$idList})"
|
|
||||||
);
|
|
||||||
Database::query(
|
|
||||||
"UPDATE content_assets a
|
|
||||||
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
|
|
||||||
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
|
|
||||||
SET a.review_status = 'draft', a.rights_status = 'review_required'
|
|
||||||
WHERE pb.curriculum_lesson_id IN ({$idList})"
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Publish and approve
|
$blockers = publicationBlockers($targetLessons, true);
|
||||||
|
if ($blockers !== []) {
|
||||||
|
throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers));
|
||||||
|
}
|
||||||
Database::query(
|
Database::query(
|
||||||
"UPDATE curriculum_lessons SET source_status = 'approved', reviewed_at = CURRENT_TIMESTAMP WHERE id IN ({$idList})"
|
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'"
|
||||||
);
|
|
||||||
Database::query(
|
|
||||||
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList})"
|
|
||||||
);
|
|
||||||
Database::query(
|
|
||||||
"UPDATE content_assets a
|
|
||||||
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
|
|
||||||
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
|
|
||||||
SET a.review_status = 'approved', a.rights_status = 'cleared'
|
|
||||||
WHERE pb.curriculum_lesson_id IN ({$idList})"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes Grade 10 textbook PDFs into content_assets and publication_bundle_assets
|
* Link previously reviewed Grade 10 textbook PDFs to published bundles.
|
||||||
* so they appear in the student app under "الكتب المقررة" for each subject.
|
* Never creates a published bundle, grants rights, or approves an asset.
|
||||||
*
|
* Usage: php backend/scripts/publish_grade10_textbooks.php [--apply]
|
||||||
* Usage:
|
|
||||||
* php backend/scripts/publish_grade10_textbooks.php [--apply]
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
@@ -15,165 +12,123 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
|
||||||
$apply = in_array('--apply', $argv, true);
|
$apply = in_array('--apply', $argv, true);
|
||||||
$projectRoot = dirname(__DIR__, 2);
|
$booksRoot = dirname(__DIR__, 2) . '/books';
|
||||||
$booksRoot = $projectRoot . '/books';
|
$curriculumRoot = dirname(__DIR__) . '/storage/curriculum';
|
||||||
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
|
$subjectMap = [
|
||||||
|
'كيمياء' => 'chemistry_10', 'الرياضيات' => 'math_10', 'الفيزياء' => 'physics_10',
|
||||||
echo "=== Publishing Grade 10 Textbooks ===\n";
|
'الأحياء' => 'biology_10', 'حياتية' => 'biology_10', 'علوم الأرض' => 'earth_sciences_10',
|
||||||
|
'اللغة العربية' => 'arabic_10', 'العربية' => 'arabic_10',
|
||||||
|
'الإنجليزية' => 'english_10', 'الانجليزية' => 'english_10',
|
||||||
|
'التربية الإسلامية' => 'islamic_10', 'الإسلامية' => 'islamic_10',
|
||||||
|
'تاريخ' => 'history_10', 'جغرافيا' => 'geography_10',
|
||||||
|
'الوطنية' => 'civics_10', 'المالية' => 'financial_10',
|
||||||
|
'الرقمية' => 'digital_skills_10', 'حاسوب' => 'digital_skills_10',
|
||||||
|
];
|
||||||
|
|
||||||
if (!is_dir($booksRoot)) {
|
if (!is_dir($booksRoot)) {
|
||||||
echo "Books directory not found at {$booksRoot}\n";
|
fwrite(STDERR, "Books directory is missing.\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdfFiles = glob($booksRoot . '/*.pdf') ?: [];
|
$ready = [];
|
||||||
echo "Found " . count($pdfFiles) . " textbook PDFs in {$booksRoot}.\n";
|
$blocked = [];
|
||||||
|
foreach (glob($booksRoot . '/*.pdf') ?: [] as $filePath) {
|
||||||
$subjectMap = [
|
|
||||||
'كيمياء' => 'chemistry_10',
|
|
||||||
'الرياضيات' => 'math_10',
|
|
||||||
'الفيزياء' => 'physics_10',
|
|
||||||
'الأحياء' => 'biology_10',
|
|
||||||
'حياتية' => 'biology_10',
|
|
||||||
'علوم الأرض' => 'earth_sciences_10',
|
|
||||||
'اللغة العربية' => 'arabic_10',
|
|
||||||
'العربية' => 'arabic_10',
|
|
||||||
'الإنجليزية' => 'english_10',
|
|
||||||
'الانجليزية' => 'english_10',
|
|
||||||
'التربية الإسلامية' => 'islamic_10',
|
|
||||||
'الإسلامية' => 'islamic_10',
|
|
||||||
'تاريخ' => 'history_10',
|
|
||||||
'جغرافيا' => 'geography_10',
|
|
||||||
'الوطنية' => 'civics_10',
|
|
||||||
'المالية' => 'financial_10',
|
|
||||||
'الرقمية' => 'digital_skills_10',
|
|
||||||
'حاسوب' => 'digital_skills_10',
|
|
||||||
];
|
|
||||||
|
|
||||||
$publishedCount = 0;
|
|
||||||
|
|
||||||
foreach ($pdfFiles as $filePath) {
|
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
$sha256 = hash_file('sha256', $filePath);
|
if (!str_contains($filename, 'كتاب الطالب')) {
|
||||||
$bytes = filesize($filePath);
|
echo "SKIP {$filename}: not a student textbook\n";
|
||||||
|
|
||||||
// Identify subject
|
|
||||||
$matchedSubject = null;
|
|
||||||
foreach ($subjectMap as $keyword => $subKey) {
|
|
||||||
if (str_contains($filename, $keyword)) {
|
|
||||||
$matchedSubject = $subKey;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$matchedSubject) {
|
|
||||||
echo " - Skipping unclassified PDF: {$filename}\n";
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$subject = null;
|
||||||
$semesterKey = str_contains($filename, 'الثاني') ? 'semester_2' : 'semester_1';
|
foreach ($subjectMap as $keyword => $key) {
|
||||||
echo "\nProcessing: [{$matchedSubject} - {$semesterKey}] {$filename}\n";
|
if (str_contains($filename, $keyword)) { $subject = $key; break; }
|
||||||
|
|
||||||
// Destination in curriculum storage
|
|
||||||
$relativeDest = "textbooks/grade_10/{$matchedSubject}/" . basename($filePath);
|
|
||||||
$fullDest = $curriculumRoot . '/' . $relativeDest;
|
|
||||||
|
|
||||||
if ($apply) {
|
|
||||||
$destDir = dirname($fullDest);
|
|
||||||
if (!is_dir($destDir)) {
|
|
||||||
mkdir($destDir, 0755, true);
|
|
||||||
}
|
|
||||||
if (!file_exists($fullDest) || hash_file('sha256', $fullDest) !== $sha256) {
|
|
||||||
copy($filePath, $fullDest);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if ($subject === null) { $blocked[] = "{$filename}: unknown subject"; continue; }
|
||||||
// Find the representative published bundle for this subject
|
if (str_contains($filename, 'الفصل الثاني')) {
|
||||||
$lesson = Database::selectOne(
|
$semester = 'semester_2';
|
||||||
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
|
} elseif (str_contains($filename, 'الفصل الأول')) {
|
||||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id
|
$semester = 'semester_1';
|
||||||
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
|
} else {
|
||||||
ORDER BY cl.unit_key ASC, cl.lesson_key ASC LIMIT 1",
|
$blocked[] = "{$filename}: explicit semester is missing";
|
||||||
[$matchedSubject, $semesterKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$lesson) {
|
|
||||||
// Fallback: any lesson in the subject
|
|
||||||
$lesson = Database::selectOne(
|
|
||||||
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
|
|
||||||
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ?
|
|
||||||
ORDER BY cl.id ASC LIMIT 1",
|
|
||||||
[$matchedSubject]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$lesson) {
|
|
||||||
echo " - No curriculum lesson found for {$matchedSubject}.\n";
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$sha = hash_file('sha256', $filePath);
|
||||||
|
if ($sha === false) { $blocked[] = "{$filename}: unreadable source"; continue; }
|
||||||
|
|
||||||
$lessonId = (int)$lesson['id'];
|
|
||||||
|
|
||||||
// Ensure publication bundle exists
|
|
||||||
$bundle = Database::selectOne(
|
|
||||||
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND status = 'published' LIMIT 1",
|
|
||||||
[$lessonId]
|
|
||||||
);
|
|
||||||
|
|
||||||
$bundleId = 0;
|
|
||||||
if ($bundle) {
|
|
||||||
$bundleId = (int)$bundle['id'];
|
|
||||||
} elseif ($apply) {
|
|
||||||
$bUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
||||||
$bundleId = (int)Database::insert(
|
|
||||||
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status, published_at)
|
|
||||||
VALUES (?, ?, 'grade10-intake-2026-09-10', 'published', NOW())",
|
|
||||||
[$bUuid, $lessonId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure content_asset exists
|
|
||||||
$asset = Database::selectOne(
|
$asset = Database::selectOne(
|
||||||
"SELECT id, uuid FROM content_assets WHERE sha256 = ? LIMIT 1",
|
"SELECT id, storage_driver, storage_key, sha256, review_status, rights_status, source_reference
|
||||||
[$sha256]
|
FROM content_assets WHERE sha256 = ? AND asset_type = 'textbook_pdf' LIMIT 1",
|
||||||
|
[$sha]
|
||||||
);
|
);
|
||||||
|
if (!$asset || $asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' ||
|
||||||
$assetId = 0;
|
trim((string)$asset['source_reference']) === '') {
|
||||||
if ($asset) {
|
$blocked[] = "{$filename}: reviewed textbook asset and rights evidence are missing";
|
||||||
$assetId = (int)$asset['id'];
|
continue;
|
||||||
echo " - Asset exists (ID: {$assetId})\n";
|
|
||||||
if ($apply) {
|
|
||||||
Database::query(
|
|
||||||
"UPDATE content_assets SET review_status = 'approved', rights_status = 'cleared', storage_key = ? WHERE id = ?",
|
|
||||||
[$relativeDest, $assetId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} elseif ($apply) {
|
|
||||||
$aUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
||||||
$assetId = (int)Database::insert(
|
|
||||||
"INSERT INTO content_assets
|
|
||||||
(uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, rights_status, review_status)
|
|
||||||
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, 'cleared', 'approved')",
|
|
||||||
[$aUuid, $relativeDest, $bytes, $sha256]
|
|
||||||
);
|
|
||||||
echo " - Created content_asset ID {$assetId}\n";
|
|
||||||
}
|
}
|
||||||
|
if ($asset['storage_driver'] !== 'local') {
|
||||||
|
$blocked[] = "{$filename}: verify the remote asset with its storage provider before linking";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$storagePath = realpath($curriculumRoot . '/' . $asset['storage_key']);
|
||||||
|
$rootPath = realpath($curriculumRoot);
|
||||||
|
if ($storagePath === false || $rootPath === false || !str_starts_with($storagePath, $rootPath . DIRECTORY_SEPARATOR) ||
|
||||||
|
!hash_equals($sha, (string)hash_file('sha256', $storagePath))) {
|
||||||
|
$blocked[] = "{$filename}: approved stored file is missing or its checksum differs";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bundle = Database::selectOne(
|
||||||
|
"SELECT pb.id FROM publication_bundles pb
|
||||||
|
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||||
|
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
|
||||||
|
AND cl.source_status = 'approved' AND pb.status = 'published'
|
||||||
|
ORDER BY cl.unit_key, cl.lesson_key, pb.id LIMIT 1",
|
||||||
|
[$subject, $semester]
|
||||||
|
);
|
||||||
|
if (!$bundle) {
|
||||||
|
$blocked[] = "{$filename}: no published bundle for {$subject}/{$semester}";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ready[] = ['name' => $filename, 'bundle_id' => (int)$bundle['id'], 'asset_id' => (int)$asset['id']];
|
||||||
|
}
|
||||||
|
|
||||||
// Link in publication_bundle_assets
|
foreach ($blocked as $reason) echo "BLOCKED {$reason}\n";
|
||||||
if ($apply && $bundleId > 0 && $assetId > 0) {
|
foreach ($ready as $row) echo "READY {$row['name']} => bundle {$row['bundle_id']}\n";
|
||||||
|
if (!$apply) {
|
||||||
|
echo "DRY RUN: " . count($ready) . " ready, " . count($blocked) . " blocked.\n";
|
||||||
|
exit($blocked === [] ? 0 : 2);
|
||||||
|
}
|
||||||
|
if ($blocked !== []) {
|
||||||
|
fwrite(STDERR, "REFUSE: resolve all textbook review and matching blockers before --apply.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
if ($ready === []) {
|
||||||
|
fwrite(STDERR, "REFUSE: no reviewed textbook to link.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getConnection();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($ready as $row) {
|
||||||
|
$current = Database::selectOne(
|
||||||
|
"SELECT pb.status, cl.source_status, a.review_status, a.rights_status
|
||||||
|
FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||||
|
JOIN content_assets a ON a.id = ? WHERE pb.id = ? FOR UPDATE",
|
||||||
|
[$row['asset_id'], $row['bundle_id']]
|
||||||
|
);
|
||||||
|
if (!$current || $current['status'] !== 'published' || $current['source_status'] !== 'approved' ||
|
||||||
|
$current['review_status'] !== 'approved' || $current['rights_status'] !== 'cleared') {
|
||||||
|
throw new RuntimeException('Approval changed while linking textbooks.');
|
||||||
|
}
|
||||||
Database::query(
|
Database::query(
|
||||||
"INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order)
|
"INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order)
|
||||||
VALUES (?, ?, 'textbook', 1)
|
VALUES (?, ?, 'textbook', 1) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order)",
|
||||||
ON DUPLICATE KEY UPDATE role = 'textbook'",
|
[$row['bundle_id'], $row['asset_id']]
|
||||||
[$bundleId, $assetId]
|
|
||||||
);
|
);
|
||||||
echo " - Linked to publication bundle {$bundleId} as textbook.\n";
|
|
||||||
$publishedCount++;
|
|
||||||
}
|
}
|
||||||
}
|
$pdo->commit();
|
||||||
|
echo "Linked " . count($ready) . " reviewed textbooks.\n";
|
||||||
if ($apply) {
|
} catch (Throwable $e) {
|
||||||
echo "\n=== Successfully published {$publishedCount} textbooks! ===\n";
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
} else {
|
fwrite(STDERR, "FAILED: {$e->getMessage()}\n");
|
||||||
echo "\nDRY RUN complete. Run with --apply to execute.\n";
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// In-memory fixture only; no writes to repository, external database or network.
|
||||||
|
require_once dirname(__DIR__) . '/app/Core/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/PublishedContentService.php';
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/CurriculumService.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\PublishedContentService;
|
||||||
|
use App\Services\CurriculumService;
|
||||||
|
|
||||||
|
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
|
||||||
|
$pdo->exec('CREATE TABLE auth_identities (id INTEGER PRIMARY KEY, status TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, grade_level TEXT, identity_id INTEGER)');
|
||||||
|
$pdo->exec("INSERT INTO auth_identities VALUES (1, 'active'), (2, 'disabled')");
|
||||||
|
$pdo->exec("INSERT INTO students VALUES (7, 'grade_10', 1), (8, 'grade_9', 1), (10, 'grade_10', 2)");
|
||||||
|
|
||||||
|
function checkContent(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$asset = ['grade_key' => 'grade_10', 'storage_driver' => 'local', 'storage_key' => 'manifest.json'];
|
||||||
|
checkContent(PublishedContentService::studentMayRead(7, $asset), 'Same-grade student denied');
|
||||||
|
checkContent(!PublishedContentService::studentMayRead(8, $asset), 'Other-grade student allowed');
|
||||||
|
checkContent(!PublishedContentService::studentMayRead(9, $asset), 'Unknown student allowed');
|
||||||
|
checkContent(!PublishedContentService::studentMayRead(10, $asset), 'Disabled identity allowed');
|
||||||
|
checkContent(PublishedContentService::readLocalAsset($asset) !== null, 'Exact in-root asset not found');
|
||||||
|
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => '../../database_schema.sql'])) === null, 'Traversal escaped storage root');
|
||||||
|
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => 'missing.md'])) === null, 'Missing file substituted');
|
||||||
|
checkContent(CurriculumService::getLessonMarkdown('../manifest.json') === '', 'Unsafe lesson read returned content');
|
||||||
|
checkContent(!CurriculumService::safeMarkdownPath('../grade_10/a.md'), 'Traversal path accepted');
|
||||||
|
checkContent(CurriculumService::safeMarkdownPath('grade_10/math_10/semester_1/unit_01/lesson_01.md'), 'Valid markdown path rejected');
|
||||||
|
echo "PASS 10 local content-access checks (not an HTTP/tenant test)\n";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Pure in-memory checks; no database, files written, or external services.
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/PublishedCurriculumTreeFilter.php';
|
||||||
|
|
||||||
|
use App\Services\PublishedCurriculumTreeFilter;
|
||||||
|
|
||||||
|
$manifest = [
|
||||||
|
'grade_10' => ['subjects' => [
|
||||||
|
'math_10' => [
|
||||||
|
'resources' => ['textbooks' => ['items' => [['file' => 'unpublished.pdf']]]],
|
||||||
|
'semesters' => ['semester_1' => ['units' => [
|
||||||
|
'unit_01' => ['lessons' => [
|
||||||
|
['id' => 'lesson_01', 'title' => 'عنوان أولي', 'file' => 'g10/math/u1/l1.md', 'outcomes' => ['غير مراجع']],
|
||||||
|
['id' => 'lesson_02', 'title' => 'غير منشور', 'file' => 'g10/math/u1/l2.md'],
|
||||||
|
]],
|
||||||
|
]]],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
'grade_11' => ['subjects' => [
|
||||||
|
'math_10' => ['semesters' => ['semester_1' => ['units' => [
|
||||||
|
'unit_01' => ['lessons' => [['id' => 'lesson_01', 'title' => 'درس آخر', 'file' => 'g10/math/u1/l1.md']]],
|
||||||
|
]]]],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
$row = [
|
||||||
|
'grade_key' => 'grade_10', 'subject_key' => 'math_10', 'semester_key' => 'semester_1',
|
||||||
|
'unit_key' => 'unit_01', 'lesson_key' => 'lesson_01', 'source_manifest_path' => 'g10/math/u1/l1.md',
|
||||||
|
'uuid' => '0e506aae-4699-49e4-a91d-aa3146e4addb', 'title' => 'العنوان المراجع',
|
||||||
|
'curriculum_version' => '2026-a', 'has_video' => true,
|
||||||
|
'primary_asset_id' => '47d85a37-bdaf-4f54-a8b8-216b619b5d04',
|
||||||
|
];
|
||||||
|
|
||||||
|
function expectTree(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectTree(PublishedCurriculumTreeFilter::filter($manifest, []) === [], 'No publications must yield an empty tree');
|
||||||
|
$visible = PublishedCurriculumTreeFilter::filter($manifest, [$row]);
|
||||||
|
expectTree(count($visible) === 1 && isset($visible['grade_10']), 'Unpublished grade leaked');
|
||||||
|
$subject = $visible['grade_10']['subjects']['math_10'];
|
||||||
|
$lessons = $subject['semesters']['semester_1']['units']['unit_01']['lessons'];
|
||||||
|
expectTree(count($lessons) === 1, 'Unpublished lesson leaked');
|
||||||
|
expectTree($lessons[0]['title'] === 'العنوان المراجع' && $lessons[0]['curriculum_lesson_id'] === $row['uuid'], 'Published identity/title not authoritative');
|
||||||
|
expectTree($lessons[0]['curriculum_version'] === '2026-a', 'Curriculum version missing');
|
||||||
|
expectTree($lessons[0]['primary_lesson_asset_id'] === $row['primary_asset_id'], 'Published markdown asset not linked');
|
||||||
|
expectTree($lessons[0]['has_video'] === true, 'Published video availability lost');
|
||||||
|
expectTree(!isset($lessons[0]['file']) && !isset($lessons[0]['outcomes']), 'Unreviewed manifest fields leaked');
|
||||||
|
expectTree($subject['resources']['textbooks']['items'] === [], 'Unpublished resource leaked');
|
||||||
|
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['source_manifest_path' => 'wrong.md'])]) === [], 'Wrong source path matched');
|
||||||
|
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['subject_key' => 'physics_10'])]) === [], 'Wrong subject matched');
|
||||||
|
echo "PASS 11 published curriculum filter checks\n";
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Pure local checks. No database, network, files written, or paid services.
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/RemediationAttemptGrader.php';
|
||||||
|
|
||||||
|
use App\Services\RemediationAttemptGrader;
|
||||||
|
|
||||||
|
$ids = [11, 12, 13];
|
||||||
|
$key = [
|
||||||
|
['question_id' => 11, 'option_ids' => [101, 102], 'correct_option_id' => 102],
|
||||||
|
['question_id' => 12, 'option_ids' => [201, 202], 'correct_option_id' => 201],
|
||||||
|
['question_id' => 13, 'option_ids' => [301, 302], 'correct_option_id' => 302],
|
||||||
|
];
|
||||||
|
$checks = 0;
|
||||||
|
function expectGrade(array $ids, array $key, array $answers, ?int $expected): void
|
||||||
|
{
|
||||||
|
global $checks;
|
||||||
|
try {
|
||||||
|
$actual = RemediationAttemptGrader::grade($ids, $key, $answers);
|
||||||
|
if ($expected === null || $actual !== $expected) throw new RuntimeException('Unexpected grade');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
if ($expected !== null) throw $e;
|
||||||
|
}
|
||||||
|
$checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectGrade($ids, $key, [102, 201, 302], 3);
|
||||||
|
expectGrade($ids, $key, [102, 202, 302], 2);
|
||||||
|
expectGrade($ids, $key, [101, 202, 301], 0);
|
||||||
|
expectGrade($ids, $key, [102, 201], null);
|
||||||
|
expectGrade($ids, $key, [102, 201, 999], null);
|
||||||
|
expectGrade($ids, $key, [102, 102, 302], null);
|
||||||
|
expectGrade($ids, $key, ['102', 201, 302], null);
|
||||||
|
expectGrade($ids, array_reverse($key), [102, 201, 302], null);
|
||||||
|
expectGrade([11, 11, 13], $key, [102, 201, 302], null);
|
||||||
|
echo "PASS {$checks} remediation grading cases\n";
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SQLite memory fixture: no production DB, no network, no money or student writes.
|
||||||
|
// MySQL/HTTP concurrency and real-school roster verification remain pending.
|
||||||
|
require_once dirname(__DIR__) . '/app/Core/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\StudentAccessControlService;
|
||||||
|
|
||||||
|
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
if ($pdo instanceof \Pdo\Sqlite) $pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
|
||||||
|
else $pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
|
||||||
|
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE directorates (id INTEGER PRIMARY KEY, type TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE schools (id INTEGER PRIMARY KEY, name TEXT, type TEXT, directorate_id INTEGER)');
|
||||||
|
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, national_id TEXT, grade_level TEXT, school_id INTEGER, is_school_sponsored INTEGER)');
|
||||||
|
$pdo->exec('CREATE TABLE teachers (id INTEGER PRIMARY KEY, is_school_exclusive INTEGER, is_marketplace_public INTEGER)');
|
||||||
|
$pdo->exec('CREATE TABLE courses (id INTEGER PRIMARY KEY, teacher_id INTEGER, school_id INTEGER, is_school_exclusive INTEGER, is_published INTEGER, price_jod NUMERIC, grade_level TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE lessons (id INTEGER PRIMARY KEY, course_id INTEGER, is_free_preview INTEGER)');
|
||||||
|
$pdo->exec('CREATE TABLE school_rosters (school_id INTEGER, claimed_student_id INTEGER, is_claimed INTEGER, grade_level TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE course_access_passes (id INTEGER PRIMARY KEY, student_id INTEGER, course_id INTEGER, pass_type TEXT, expires_at TEXT, is_active INTEGER)');
|
||||||
|
$pdo->exec("INSERT INTO schools VALUES (1,'School A','private',NULL),(2,'School B','private',NULL)");
|
||||||
|
$pdo->exec("INSERT INTO students VALUES (1,'a','grade_10',NULL,0),(2,'b','grade_10',1,1),(3,'c','grade_10',2,1),(4,'d','grade_9',NULL,0),(5,'e','grade_10',1,1)");
|
||||||
|
$pdo->exec('INSERT INTO teachers VALUES (1,0,1),(2,1,0)');
|
||||||
|
$pdo->exec("INSERT INTO courses VALUES (10,1,NULL,0,1,10,'grade_10'),(11,2,1,1,1,30,'grade_10'),(12,2,2,1,1,30,'grade_10'),(13,1,NULL,0,1,0,'grade_10'),(14,1,NULL,0,0,0,'grade_10')");
|
||||||
|
$pdo->exec('INSERT INTO lessons VALUES (100,10,0),(110,11,0),(120,12,0),(130,13,0),(140,14,0)');
|
||||||
|
$pdo->exec("INSERT INTO school_rosters VALUES (1,2,1,'grade_10'),(2,3,1,'grade_10')");
|
||||||
|
$pdo->exec("INSERT INTO course_access_passes VALUES (1,1,10,'full_marketplace',NULL,1),(2,2,10,'school_included',NULL,1)");
|
||||||
|
|
||||||
|
$checks = 0;
|
||||||
|
function expectAccess(int $studentId, int $courseId, int $lessonId, bool $allowed, string $reason, bool $legacySideEffects = false): void
|
||||||
|
{
|
||||||
|
global $checks;
|
||||||
|
$result = StudentAccessControlService::validateLessonAccess($studentId, null, 'grade_10', $courseId, $lessonId, $legacySideEffects);
|
||||||
|
if ((bool)$result['allowed'] !== $allowed || (string)$result['reason'] !== $reason) {
|
||||||
|
throw new RuntimeException("Unexpected access for student {$studentId}, course {$courseId}: " . json_encode($result));
|
||||||
|
}
|
||||||
|
$checks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectAccess(1, 10, 100, true, 'paid_pass_active');
|
||||||
|
expectAccess(1, 11, 110, false, 'school_scope_mismatch');
|
||||||
|
expectAccess(2, 11, 110, true, 'school_course_included');
|
||||||
|
expectAccess(2, 12, 120, false, 'school_scope_mismatch');
|
||||||
|
expectAccess(2, 10, 100, false, 'payment_required');
|
||||||
|
expectAccess(2, 13, 130, true, 'free_course');
|
||||||
|
expectAccess(5, 11, 110, false, 'school_roster_unverified');
|
||||||
|
expectAccess(4, 13, 130, false, 'grade_mismatch', true);
|
||||||
|
expectAccess(1, 14, 140, false, 'course_unavailable');
|
||||||
|
expectAccess(1, 10, 110, false, 'lesson_course_mismatch');
|
||||||
|
expectAccess(99, 13, 130, false, 'unauthenticated_or_not_found');
|
||||||
|
|
||||||
|
$grade = $pdo->query('SELECT grade_level FROM students WHERE id=4')->fetchColumn();
|
||||||
|
$passes = (int)$pdo->query('SELECT COUNT(*) FROM course_access_passes')->fetchColumn();
|
||||||
|
if ($grade !== 'grade_9' || $passes !== 2) throw new RuntimeException('Access reads changed grade or issued passes');
|
||||||
|
$checks++;
|
||||||
|
echo "PASS {$checks} local student-access scope checks (not an HTTP/MySQL test)\n";
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Isolated SQLite memory fixture. No server, production database, or money movement.
|
||||||
|
// FOR UPDATE concurrency semantics remain a separate MySQL staging test.
|
||||||
|
require_once dirname(__DIR__) . '/app/Core/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/TeacherLedgerService.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\TeacherLedgerService;
|
||||||
|
|
||||||
|
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
if ($pdo instanceof \Pdo\Sqlite) {
|
||||||
|
$pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
|
||||||
|
} else {
|
||||||
|
$pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
|
||||||
|
}
|
||||||
|
$property = new ReflectionProperty(Database::class, 'instance');
|
||||||
|
$property->setValue(null, $pdo);
|
||||||
|
$pdo->exec('CREATE TABLE ledger_accounts (id INTEGER PRIMARY KEY, code TEXT, owner_type TEXT, owner_id INTEGER, currency TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE ledger_entries (id INTEGER PRIMARY KEY, debit_account_id INTEGER, credit_account_id INTEGER, amount_fils INTEGER)');
|
||||||
|
$pdo->exec('CREATE TABLE teacher_withdrawal_holds (id INTEGER PRIMARY KEY, uuid TEXT, teacher_id INTEGER, amount_fils INTEGER, status TEXT, idempotency_key TEXT, released_at TEXT, release_reason TEXT)');
|
||||||
|
|
||||||
|
function checkLedger(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$empty = TeacherLedgerService::balance(42);
|
||||||
|
checkLedger($empty['account_id'] === null && $empty['available_fils'] === 0, 'Missing account must have zero available');
|
||||||
|
checkLedger((int)$pdo->query('SELECT COUNT(*) FROM ledger_accounts')->fetchColumn() === 0, 'Balance read created an account');
|
||||||
|
|
||||||
|
$pdo->exec("INSERT INTO ledger_accounts VALUES (1, 'teacher_available', 'teacher', 42, 'JOD')");
|
||||||
|
$pdo->exec('INSERT INTO ledger_entries VALUES (1, 2, 1, 2000), (2, 1, 2, 500)');
|
||||||
|
$pdo->exec("INSERT INTO teacher_withdrawal_holds VALUES (1, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 42, 400, 'held', 'fixture-key', NULL, NULL)");
|
||||||
|
$balance = TeacherLedgerService::balance(42);
|
||||||
|
checkLedger($balance['gross_fils'] === 1500 && $balance['held_fils'] === 400 && $balance['available_fils'] === 1100, 'Signed net balance or hold subtraction wrong');
|
||||||
|
|
||||||
|
$wrongTeacher = TeacherLedgerService::releaseHold(43, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'سبب مراجعة');
|
||||||
|
checkLedger($wrongTeacher['status'] === 'hold_not_releasable', 'Cross-teacher release was allowed');
|
||||||
|
$released = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'فشل نهائي موثق');
|
||||||
|
checkLedger($released['status'] === 'released', 'First release failed');
|
||||||
|
$again = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'إعادة');
|
||||||
|
checkLedger($again['status'] === 'hold_not_releasable', 'Second release changed state');
|
||||||
|
$row = $pdo->query('SELECT status, release_reason FROM teacher_withdrawal_holds WHERE id=1')->fetch(PDO::FETCH_ASSOC);
|
||||||
|
checkLedger($row['status'] === 'released' && $row['release_reason'] === 'فشل نهائي موثق', 'Release reason not persisted');
|
||||||
|
checkLedger(TeacherLedgerService::balance(42)['available_fils'] === 1500, 'Released hold still reduced availability');
|
||||||
|
|
||||||
|
$invalid = TeacherLedgerService::placeWithdrawalHold(42, 0, 'valid-key-1234567');
|
||||||
|
checkLedger($invalid['http_status'] === 400, 'Zero hold should be rejected before database access');
|
||||||
|
echo "PASS 9 local ledger checks (not a MySQL concurrency test)\n";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Pure local validation: no database, network, files written, or AI calls.
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/VideoCoverageValidator.php';
|
||||||
|
|
||||||
|
use App\Services\VideoCoverageValidator;
|
||||||
|
|
||||||
|
$sha = str_repeat('a', 64);
|
||||||
|
$valid = [
|
||||||
|
'video_sha256' => $sha,
|
||||||
|
'markdown_sha256' => str_repeat('b', 64),
|
||||||
|
'truncated' => false,
|
||||||
|
'gaps' => [],
|
||||||
|
'segments' => [
|
||||||
|
['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t1','visual_ref'=>'v1'],
|
||||||
|
['start_seconds'=>30,'end_seconds'=>60,'transcript_ref'=>'t2','visual_ref'=>'v2'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$cases = [
|
||||||
|
[$valid, true],
|
||||||
|
[array_replace($valid, ['truncated'=>true]), false],
|
||||||
|
[array_replace($valid, ['gaps'=>[[30,31]]]), false],
|
||||||
|
[array_replace($valid, ['video_sha256'=>str_repeat('c',64)]), false],
|
||||||
|
[array_replace($valid, ['segments'=>[['start_seconds'=>1,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
|
||||||
|
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>59,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
|
||||||
|
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t','visual_ref'=>'v'],['start_seconds'=>31,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
|
||||||
|
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>60,'transcript_ref'=>'','visual_ref'=>'v']]]), false],
|
||||||
|
];
|
||||||
|
foreach ($cases as $index => [$coverage, $expected]) {
|
||||||
|
if (VideoCoverageValidator::valid($coverage, 60, $sha) !== $expected) {
|
||||||
|
throw new RuntimeException("Coverage case {$index} failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo 'PASS ' . count($cases) . " video coverage cases\n";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Pure local validation only; no database, network, writes, or AI calls.
|
||||||
|
require_once dirname(__DIR__) . '/app/Services/VideoReviewReportValidator.php';
|
||||||
|
|
||||||
|
use App\Services\VideoReviewReportValidator;
|
||||||
|
|
||||||
|
$good = [
|
||||||
|
'missing_inputs' => [], 'issues' => [],
|
||||||
|
'coverage' => [
|
||||||
|
'truncated' => false, 'unprocessed_intervals' => [],
|
||||||
|
'section_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
|
||||||
|
'objective_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$cases = [
|
||||||
|
[$good, true],
|
||||||
|
[array_replace($good, ['missing_inputs'=>['transcript']]), false],
|
||||||
|
[array_replace_recursive($good, ['coverage'=>['truncated'=>true]]), false],
|
||||||
|
[array_replace_recursive($good, ['coverage'=>['unprocessed_intervals'=>[[0,5]]]]), false],
|
||||||
|
[array_replace_recursive($good, ['coverage'=>['section_results'=>[['status'=>'missing']]]]), false],
|
||||||
|
[array_replace($good, ['issues'=>[['severity'=>'major','resolution_status'=>'open']]]), false],
|
||||||
|
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved']]]), false],
|
||||||
|
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved','resolution_evidence'=>'reviewer-note-1']]]), true],
|
||||||
|
];
|
||||||
|
foreach ($cases as $index => [$report, $expected]) {
|
||||||
|
if (VideoReviewReportValidator::approvable($report) !== $expected) {
|
||||||
|
throw new RuntimeException("Report case {$index} failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo 'PASS ' . count($cases) . " video review report cases\n";
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# 📋 دفتر متابعة وتنفيذ مهام منصة صَقِل — السجل المعماري الحي
|
# 📋 دفتر متابعة وتنفيذ مهام منصة صَقِل — السجل المعماري الحي
|
||||||
|
|
||||||
تاريخ التحديث الأخير: **16 أيلول 2026**
|
تاريخ التحديث الأخير: **23 أيلول 2026**
|
||||||
المرجع الأساسي: `docs/NEXT_PHASE_MASTER_SPEC.md` و `docs/NEXT_PHASE_EXECUTION_BACKLOG.md` و `AGENTS.md`
|
المرجع الأساسي: `docs/NEXT_PHASE_MASTER_SPEC.md` و `docs/NEXT_PHASE_EXECUTION_BACKLOG.md` و `AGENTS.md`
|
||||||
الهدف: هذا الدفتر هو المرجع التوثيقي والتنفيذي المباشر لجميع محاور ومنعطفات منصة صَقِل، يوضح بدقة متناهية **ما تم إنجازه فعلياً في الكود**، و**شو تبقى من كل نقطة**، مع معايير القبول وشروط الانتقال للمرحلة التالية.
|
الهدف: هذا الدفتر هو المرجع التوثيقي والتنفيذي المباشر لجميع محاور ومنعطفات منصة صَقِل، يوضح بدقة متناهية **ما تم إنجازه فعلياً في الكود**، و**شو تبقى من كل نقطة**، مع معايير القبول وشروط الانتقال للمرحلة التالية.
|
||||||
|
|
||||||
@@ -13,18 +13,18 @@
|
|||||||
| المعرّف | المحور / المهمة | النطاق والمستوى | الحالة الحالية | ما تم إنجازه في الكود | شو تبقى لإتمامه بدقة |
|
| المعرّف | المحور / المهمة | النطاق والمستوى | الحالة الحالية | ما تم إنجازه في الكود | شو تبقى لإتمامه بدقة |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| **NP-00** | بيئة الاختبار وخط الأساس الآمن | البنية التحتية / P0 | `implemented_unverified` | منع Runtime DDL، فحص syntax لكل PHP، سكربت `run_migrations.php` مع مطابقة Checksum | تجهيز بيئة staging معزولة، تشغيل suite تكامل متصل بـ MySQL و Redis |
|
| **NP-00** | بيئة الاختبار وخط الأساس الآمن | البنية التحتية / P0 | `implemented_unverified` | منع Runtime DDL، فحص syntax لكل PHP، سكربت `run_migrations.php` مع مطابقة Checksum | تجهيز بيئة staging معزولة، تشغيل suite تكامل متصل بـ MySQL و Redis |
|
||||||
| **NP-01** | إيقاف النجاح والمحتوى المصطنع | الأمان والمصداقية / P0 | `verified_in_code` | تعطيل الاعتماد التلقائي لكليك، تعطيل سحب الأرباح التقديري، إزالة التوليد في GET، رد صريح بـ `unavailable` | جرد تنظيف الجداول من أي بيانات demo قديمة بعد المطابقة اليدوية |
|
| **NP-01** | إيقاف النجاح والمحتوى المصطنع | الأمان والمصداقية / P0 | `in_progress` | تعطيل الاعتماد التلقائي لكليك، تعطيل سحب الأرباح التقديري، إزالة التوليد في GET ومسار الدرس الخام | جرد بقايا الفروع وبيانات demo القديمة، واختبار فشل الخادم/المحتوى عبر التطبيقات |
|
||||||
| **NP-02** | الهوية المنهجية وحزم الأصول | المناهج والمحتوى / P0 | `in_progress` | 13 مبحثاً (392 درس ماركداون + 371 مواصفة مختبر)، جداول `curriculum_lessons` و `content_assets` | مراجعة واعتماد أول حزمة نشر (Bundle) رسمية لـ 12 درساً نموذجياً وربط الـ PDF الرسمي |
|
| **NP-02** | الهوية المنهجية وحزم الأصول | المناهج والمحتوى / P0 | `in_progress` | ملفات المنهج وجداول الأصول؛ بوابة النشر تشترط مراجعة وحقوقاً؛ شجرة الطالب ترشح المنشور فقط وتفصل فهرس تحضير المعلم | مراجعة واعتماد 12 درساً فعلياً، رفع PDF، وتشغيل بوابة النشر على staging واختبار المسارين |
|
||||||
| **NP-03** | حصة المعلم والاستبدال الذري | استوديو المعلم / P1 | `verified_in_code` | `TeacherSubmissionService` مع Preflight و Idempotency-Key وقفل المعاملات، وربط التحقق في `TeacherStudioCubit` وراية التنبيه بالنسخة المرشحة في الواجهة | اختبارات E2E لرفع الفيديو الفعلي ومطابقة الصوت |
|
| **NP-03** | حصة المعلم والاستبدال الذري | استوديو المعلم / P1 | `implemented_unverified` | Preflight ونسخة مرشحة؛ أُصلح بقاء النسخة القديمة منشورة أثناء الرفع، وربط إنشاء job بمعاملة الرفع | اختبار تزامن ورفع/استبدال E2E، والتحقق من كل مسارات النشر |
|
||||||
| **NP-04** | تدقيق الفيديو والأسئلة السقراطية | الجودة الأكاديمية / P0 | `in_progress` | مشغل قراءة صارم، إيقاف التوليد في GET، فحص Checkpoints المنشورة فقط، عقد البرومبتات | ربط الـ Worker بتفريغ صوتي حقيقي وأدلة مرئية كاملة للحصص المنشورة |
|
| **NP-04** | تدقيق الفيديو والأسئلة السقراطية | الجودة الأكاديمية / P0 | `in_progress` | أُغلق النشر الإداري المباشر؛ فحص تغطية زمنية كاملة وربط hashes قبل الاعتماد والنشر | ربط Worker بتفريغ ودليل بصري فعليين، مراجعة بشرية، واختبارات تكامل وإصدارات متزامنة |
|
||||||
| **NP-05** | اختيار المعلم (0 / 1 / متعدد) | تجربة الطالب / P1 | `implemented_unverified` | `TeacherSelectionSheet` بالفرز التنازلي للتقييم، تشغيل الـ UUID المختار، كشف المعلم المصرح فقط | ربط شجرة المنهج في التطبيق بهوية الدرس المنشور بعد تفعيل الـ Bundles |
|
| **NP-05** | اختيار المعلم (0 / 1 / متعدد) | تجربة الطالب / P1 | `implemented_unverified` | ترتيب خادمي مستقر، عدّ مرشّح بالصلاحية، دفعات 20 مع «عرض المزيد»، وتشغيل UUID المختار | اختبار MySQL/HTTP بحالات 0/1/2/100+ ومدرستين ونسخ بديلة؛ قياس تكلفة التصفح |
|
||||||
| **NP-06** | دفتر الحسابات وحجز الرصيد | المحاسبة المالية / P0 | `implemented_unverified` | `TeacherLedgerService` بوحدة الفلس، قيود القيد المزدوج، حجز السحب الذري بـ Idempotency | تسوية الرصيد الافتتاحي وتدقيق الصلاحيات متعدية الحسابات لمنع IDOR |
|
| **NP-06** | دفتر الحسابات وحجز الرصيد | المحاسبة المالية / P0 | `implemented_unverified` | جداول قيد مزدوج بالفلس، حجز بمفتاح إعادة وقفل صف المعلم، وقراءة رصيد بلا كتابة | اختبار تزامن MySQL فعلي، تسوية افتتاحية، تدقيق IDOR وبناء دورة التحويل/العكس المثبتة |
|
||||||
| **NP-07** | قياس الدقائق المستحقة بالخادم | استحقاق المشاهدة / P1 | `implemented_unverified` | `WatchSessionService` مع فترات متصلة وحساب Server-side Delta ومقارنة زمن الخادم والعميل | اختبار الإجهاد وانقطاع الاتصال ومحاكاة التلاعب بسرعة المشغل |
|
| **NP-07** | قياس الدقائق المستحقة بالخادم | استحقاق المشاهدة / P1 | `implemented_unverified` | `WatchSessionService` مع فترات متصلة وحساب Server-side Delta ومقارنة زمن الخادم والعميل | اختبار الإجهاد وانقطاع الاتصال ومحاكاة التلاعب بسرعة المشغل |
|
||||||
| **NP-08** | توزيع الإيراد وكشف حساب المعلم | المالية والشفافية / P1 | `blocked_policy` | فصل أوعية الدخل (مدرسي / مستقل)، إيقاف افتراض أرباح من حجم المنصة ككل | **قرار تجاري معلق:** حسم سعر الدقيقة، ونسبة صندوق المعلمين، وفترة التسوية |
|
| **NP-08** | توزيع الإيراد وكشف حساب المعلم | المالية والشفافية / P1 | `blocked_policy` | فصل أوعية الدخل (مدرسي / مستقل)، إيقاف افتراض أرباح من حجم المنصة ككل | **قرار تجاري معلق:** حسم سعر الدقيقة، ونسبة صندوق المعلمين، وفترة التسوية |
|
||||||
| **NP-09** | بوابة الدفع والبصمة والتحويل | الدفع المالي / P0 | `blocked_psp` | عزل مسارات كليك بوضع غير متاح بدلاً من المحاكاة الصورية والاحتيال | **تعاقد مصرفي معلق:** ربط PSP أردني معتمد، وبناء Webhooks موقعة، وبصمة الجهاز |
|
| **NP-09** | بوابة الدفع والبصمة والتحويل | الدفع المالي / P0 | `blocked_psp` | عزل مسارات كليك بوضع غير متاح بدلاً من المحاكاة الصورية والاحتيال | **تعاقد مصرفي معلق:** ربط PSP أردني معتمد، وبناء Webhooks موقعة، وبصمة الجهاز |
|
||||||
| **NP-10** | حزمة الإنجليزية والصوت الموحد | المناهج التفاعلية / P1 | `in_progress` | تطوير مختبر الإنجليزية، تنظيم ملفات الوحدات والقواعد، إزالة IPA التخميني | توفير ملفات الصوت البشري المعتمدة للمفردات والنصوص وربطها بروابط CDN |
|
| **NP-10** | حزمة الإنجليزية والصوت الموحد | المناهج التفاعلية / P1 | `in_progress` | تطوير مختبر الإنجليزية، تنظيم ملفات الوحدات والقواعد، إزالة IPA التخميني | توفير ملفات الصوت البشري المعتمدة للمفردات والنصوص وربطها بروابط CDN |
|
||||||
| **NP-11** | إكمال حلقة التعلم ودفتر الأخطاء | التجربة التعليمية / P1 | `verified_in_code` | 18 مختبراً، نافذة التفكير، دورة التكرار المتباعد بمرحلتين (تثبيت + استرجاع 7 أيام) بالخادم والواجهة | ربط واجهات الواجبات المدرسية بنظام مهام وتكليفات المعلم |
|
| **NP-11** | إكمال حلقة التعلم ودفتر الأخطاء | التجربة التعليمية / P1 | `implemented_unverified` | 18 مختبراً وواجهة العلاج؛ أضيفت محاولة بأسئلة منشورة وتصحيح خادم وموعد استرجاع بعد 7 أيام | تطبيق migration واختبار تكامل وتزامن، مراجعة مصدر الأسئلة، ربط الواجبات والتذكير |
|
||||||
| **NP-12** | المؤسسات والمديريات والإشراف | إدارة التعليم / P1 | `implemented_unverified` | لوحات SuperAdmin والمديريات بقراءة حقيقية، عزل أدوار الـ Middlewares، blind index | استيراد شعب المدارس الحكومية والخاصة واختبارات العزل التام بين المدارس |
|
| **NP-12** | المؤسسات والمديريات والإشراف | إدارة التعليم / P1 | `in_progress` | لوحات تقرأ البيانات؛ أُزيل منح دورة مجانية لكل طالب مدرسي، وأضيف فحص مدرسة/كشف انتساب ودور تغيير الصف | استيراد كشوف مؤكدة، عقود صلاحية المباحث، اختبارات IDOR وMySQL/HTTP لمدرستين |
|
||||||
| **NP-13** | إثبات الصف العاشر والإطلاق | بوابة الإطلاق النهائي | `gate_pending` | جاهزية التطبيقات الأربعة، مسار التدريب المهني (VTC)، صفحة الهبوط الفاخرة | إجراء التجربة الحية على 12 درساً للصف العاشر مع عينة حقيقية من المعلمين والطلبة |
|
| **NP-13** | إثبات الصف العاشر والإطلاق | بوابة الإطلاق النهائي | `gate_pending` | جاهزية التطبيقات الأربعة، مسار التدريب المهني (VTC)، صفحة الهبوط الفاخرة | إجراء التجربة الحية على 12 درساً للصف العاشر مع عينة حقيقية من المعلمين والطلبة |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -59,11 +59,14 @@
|
|||||||
### [NP-02] الهوية المنهجية وحزم الملفات (Curriculum Identity & Bundles)
|
### [NP-02] الهوية المنهجية وحزم الملفات (Curriculum Identity & Bundles)
|
||||||
* **الهدف:** تمييز كل درس بهوية ثابتة (الصف، المبحث، الفصل، الوحدة، الدرس، النسخة)، وربط الأصول كحزم نشر موثوقة.
|
* **الهدف:** تمييز كل درس بهوية ثابتة (الصف، المبحث، الفصل، الوحدة، الدرس، النسخة)، وربط الأصول كحزم نشر موثوقة.
|
||||||
* **ما تم إنجازه في الكود:**
|
* **ما تم إنجازه في الكود:**
|
||||||
- 13 مبحثاً كاملاً للصف العاشر في `backend/storage/curriculum/grade_10` تضم 392 ملف ماركداون.
|
- فهرس الصف العاشر المحلي يحوي 392 ملف Markdown؛ العدد لا يثبت اكتمال المباحث أو صحة المحتوى أو الموافقة على نشره.
|
||||||
- 371 وثيقة مواصفات للمختبرات الافتراضية في `docs/virtual_labs/grade_10`.
|
- يوجد 370 ملفاً في `docs/virtual_labs/grade_10` وفق جرد الملفات المحلي، ولا تعني مراجعة علمية.
|
||||||
- تطبيق جدول `curriculum_lessons` وجدول `content_assets` وجدول `publication_bundles`.
|
- تعريف migrations لجداول `curriculum_lessons` و`content_assets` و`publication_bundles`؛ تطبيقها على staging/الإنتاج غير مثبت هنا.
|
||||||
- نقطة `GET /api/curriculum/assets/{assetId}` التي تخدم الأصول عبر UUID فقط دون تمرير مسارات ملفات من العميل.
|
- نقطة `GET /api/curriculum/assets/{assetId}` التي تخدم الأصول عبر UUID فقط دون تمرير مسارات ملفات من العميل.
|
||||||
- سكربت `publish_grade10_bundle.php` المخصص لنشر حزم الصف العاشر مع خيار `--pilot` للدروس الـ 12 النموذجية.
|
- سكربت `publish_grade10_bundle.php` المخصص لنشر حزم الصف العاشر مع خيار `--pilot` للدروس الـ 12 النموذجية.
|
||||||
|
- بوابة `--pilot` تتحقق من 12 ملفات من نوع درس، وتمنع النشر عندما يغيب مراجع الدرس أو تاريخ مراجعته أو اعتماد الأصل أو تصفية حقوقه. السحب يحفظ قرار المراجعة وسجل الحقوق.
|
||||||
|
- الشجرة العامة للطالب لا تظهر إلا درساً له سجل منهجي معتمد وحزمة وأصل منشوران؛ فهرس تحضير المعلم منفصل خلف صلاحية الدور.
|
||||||
|
- الشجرة العامة ترسل UUID أصل Markdown الأساسي؛ قارئ الدرس يستعمله بدلاً من مسار ملف manifest أو UUID الدرس. مسارات `/api/curriculum/lesson` و`/api/curriculum/document` التي تقبل `file` صارت محصورة بأدوار إدارة المنهج.
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
- [ ] مراجعة واعتماد الحزم المنشورة للدفعة الأولى ونقلها من حالة `draft` إلى `published`.
|
- [ ] مراجعة واعتماد الحزم المنشورة للدفعة الأولى ونقلها من حالة `draft` إلى `published`.
|
||||||
- [ ] رفع ملفات الـ PDF الرسمية للكتب وأوراق العمل وتخزينها في التخزين السيادي كأصول ذات Checksum.
|
- [ ] رفع ملفات الـ PDF الرسمية للكتب وأوراق العمل وتخزينها في التخزين السيادي كأصول ذات Checksum.
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
- إضافة شريط التنبيه المرئي في واجهة الرفع `apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart` لإعلام المعلم بصراحة: "نسخة مرشحة للاستبدال (إصدار X) — الحصة المنشورة الحالية تظل متاحة للطلاب حتى اجتياز الفحص".
|
- إضافة شريط التنبيه المرئي في واجهة الرفع `apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart` لإعلام المعلم بصراحة: "نسخة مرشحة للاستبدال (إصدار X) — الحصة المنشورة الحالية تظل متاحة للطلاب حتى اجتياز الفحص".
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
- [ ] اختبارات E2E لرفع الفيديو الفعلي ومطابقة الصوت الميداني.
|
- [ ] اختبارات E2E لرفع الفيديو الفعلي ومطابقة الصوت الميداني.
|
||||||
|
- [ ] اختبار أن حصة المعلم القديمة تظل مرئية للطالب أثناء رفع ومراجعة البديلة، وأن فشل إنشاء job يرد الرفع إلى حالته السابقة ضمن المعاملة.
|
||||||
- [ ] إشعار المعلم اللحظي عند انتهاء التدقيق البشري للنسخة المرشحة.
|
- [ ] إشعار المعلم اللحظي عند انتهاء التدقيق البشري للنسخة المرشحة.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -99,10 +103,11 @@
|
|||||||
### [NP-05] اختيار المعلم (0 / 1 / متعدد)
|
### [NP-05] اختيار المعلم (0 / 1 / متعدد)
|
||||||
* **الهدف:** تطبيق منطق صَقِل: صفر حصص = إظهار الموارد المتاحة بصدق، حصة واحدة = تشغيل مباشر، أكثر من حصة = قائمة بالتقييم الموثق.
|
* **الهدف:** تطبيق منطق صَقِل: صفر حصص = إظهار الموارد المتاحة بصدق، حصة واحدة = تشغيل مباشر، أكثر من حصة = قائمة بالتقييم الموثق.
|
||||||
* **ما تم إنجازه في الكود:**
|
* **ما تم إنجازه في الكود:**
|
||||||
- واجهة `TeacherSelectionSheet` في تطبيق الطالب بفرز تنازلي للتقييم الحقيقي وعدد التقييمات وشارة التوثيق.
|
- واجهة `TeacherSelectionSheet` تعرض ترتيب الخادم ولا تعيد فرزه، وتطلب صفحات إضافية عند الحاجة؛ أزيلت أوصاف أساليب المعلمين المختلقة. تعرض المدة وتاريخ النشر إن أرسلهما الخادم.
|
||||||
- نقطة `GET /api/curriculum/lessons/{lessonId}/videos` التي تعيد فقط النسخ المنشورة والمصرح بها للطالب.
|
- نقطة `GET /api/curriculum/lessons/{lessonId}/videos` تمسح المرشحين على دفعات ثابتة بدلاً من قصّ أول 101 صف، وتعيد العدد والصفحة بعد فحص صلاحية الطالب؛ شرط `EXISTS` يمنع تكرار الحصة عند وجود عدة أصول/وظائف مراجعة.
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
- [ ] ربط شجرة المنهج في واجهة الطالب (`subject_hub_screen.dart`) بالـ UUID المعتمد للحزم المنشورة عند توفرها.
|
- [ ] ربط شجرة المنهج في واجهة الطالب (`subject_hub_screen.dart`) بالـ UUID المعتمد للحزم المنشورة عند توفرها.
|
||||||
|
- [ ] اختبار قائمة 0/1/2/100+، ترتيب التساوي، صلاحيات مدرستين، وتبدل التقييم أو سحب الحصة بين الصفحات على MySQL وواجهة حقيقية. العدّ صحيح داخل طلب واحد عند ثبات snapshot؛ لا يضمن offset ثبات الصفحات بين طلبين إذا تغير النشر أو التقييم.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,11 +115,14 @@
|
|||||||
* **الهدف:** نظام محاسبي صارم بوحدة الفلس (بدون فواصل عشرية عائمة)، وقيد مزدوج يمنع السحب المتجاوز للرصيد.
|
* **الهدف:** نظام محاسبي صارم بوحدة الفلس (بدون فواصل عشرية عائمة)، وقيد مزدوج يمنع السحب المتجاوز للرصيد.
|
||||||
* **ما تم إنجازه في الكود:**
|
* **ما تم إنجازه في الكود:**
|
||||||
- خدمة `TeacherLedgerService` الداعمة لـ `balance` و `placeWithdrawalHold` و `releaseHold`.
|
- خدمة `TeacherLedgerService` الداعمة لـ `balance` و `placeWithdrawalHold` و `releaseHold`.
|
||||||
- حجز السحب ذري (Atomic Transaction) يمنع طلبين متزامنين يتجاوزان الرصيد الفعلي.
|
- الحجز يقفل صف `teachers` للمعلم قبل قراءة الرصيد أو الحجز، ليُسلسل الطلبات المتزامنة لنفس المعلم؛ هذه آلية كود لم تُختبر بعد بتزامن MySQL فعلي. قراءة الرصيد لم تعد تنشئ حساباً عند غيابه، وحساب صافي القيود يستخدم أعداداً موقعة قبل الطرح.
|
||||||
- جداول `ledger_accounts` و `ledger_entries` و `teacher_withdrawal_holds`.
|
- جداول `ledger_accounts` و `ledger_entries` و `teacher_withdrawal_holds`.
|
||||||
|
- migration `20260923_teacher_hold_release_reason.sql` تحفظ سبب تحرير الحجز في السجل، ولا تخترع سبباً لتحريرات سابقة.
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
|
- [ ] تطبيق migration على staging قبل نشر الكاتب، ثم اختبار طلبَي حجز متزامنين وretry بمفتاح واحد وآخر مختلف، والتحرير المكرر، والرصيد السالب/الصفر، وفشل منتصف المعاملة.
|
||||||
- [ ] تسوية الأرصدة الافتتاحية السابقة ومطابقتها قبل تفعيل أي قيد حي.
|
- [ ] تسوية الأرصدة الافتتاحية السابقة ومطابقتها قبل تفعيل أي قيد حي.
|
||||||
- [ ] تدقيق أمان الـ IDOR للتأكد من عدم وصول أي معلم لدفتر معلم آخر.
|
- [ ] تدقيق أمان الـ IDOR للتأكد من عدم وصول أي معلم لدفتر معلم آخر.
|
||||||
|
- [ ] كل كاتب قيود/صرف مستقبلي يجب أن يلتزم بقفل صف المعلم ذاته قبل تغيير رصيده؛ الخدمة ليست موصولة بمسار دفع/سحب حي، ولا تثبت وحدها سلامة تحويل الأموال أو توازن كل قيد تاريخي.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,12 +177,12 @@
|
|||||||
* **ما تم إنجازه في الكود:**
|
* **ما تم إنجازه في الكود:**
|
||||||
- 18 مختبراً تفاعلياً مدمجاً ومفحوصاً باختبارات Smoke (12 لغة عربية + 6 فيزياء).
|
- 18 مختبراً تفاعلياً مدمجاً ومفحوصاً باختبارات Smoke (12 لغة عربية + 6 فيزياء).
|
||||||
- نافذة التفكير الإلزامي `ScaffoldedThinkingPauseSheet` (60 ثانية) مع التدرج المعرفي خطوة بخطوة.
|
- نافذة التفكير الإلزامي `ScaffoldedThinkingPauseSheet` (60 ثانية) مع التدرج المعرفي خطوة بخطوة.
|
||||||
- تطبيق دورة التكرار المتباعد بمرحلتين (Two-Stage Spaced Repetition) في `ErrorNotebookController.php`:
|
- أضيفت migration `20260923_error_notebook_verified_attempts.sql` لحفظ المحاولة وموعد `retention_due_at` وحقول مراجعة أسئلة العلاج. التصحيح يجري على الخادم بثلاثة أسئلة منشورة ومراجعة بشرياً من نفس الدرس والمفهوم، مع خيار صحيح واحد لكل سؤال، ومفتاح إجابة محفوظ للمحاولة لا يصل إلى التطبيق. اجتياز المرحلة الأولى يحدد موعد المراجعة بعد 7 أيام؛ اجتياز الثانية بعد الموعد ينقل الحالة إلى `mastered`.
|
||||||
- المرحلة الأولى (التدريب العلاجي الأولي): ينتقل الخطأ من `pending_remediation` إلى `in_remediation` وتتم جدولة اختبار الاسترجاع بعد 7 أيام (`retention_scheduled_at = NOW() + 7 DAYS`).
|
|
||||||
- المرحلة الثانية (تحدي الاسترجاع والتثبيت): عند اجتياز الطالب للاختبار بعد اكتمال الفترة ينتقل إلى `mastered` مع تسجيل تاريخ الإتقان الفعلي.
|
|
||||||
- شاشة دفتر الأخطاء الذكي `SmartErrorNotebookScreen`: إضافة تبويبات التصفية بحسب الحالة (الكل، بانتظار المعالجة، قيد التثبيت 🌱، تم الإتقان ✨)، وصندوق إحصائي مستقل لقيد التثبيت، وزر خاص لبدء اختبار الاسترجاع والتثبيت المتباعد.
|
- شاشة دفتر الأخطاء الذكي `SmartErrorNotebookScreen`: إضافة تبويبات التصفية بحسب الحالة (الكل، بانتظار المعالجة، قيد التثبيت 🌱، تم الإتقان ✨)، وصندوق إحصائي مستقل لقيد التثبيت، وزر خاص لبدء اختبار الاسترجاع والتثبيت المتباعد.
|
||||||
- منظومة ربط ولي الأمر المشروطة بموافقة صريحة من الطالب.
|
- منظومة ربط ولي الأمر المشروطة بموافقة صريحة من الطالب.
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
|
- [ ] تطبيق migration على staging بعد نسخة احتياطية، واختبار المحاولة الصحيحة والخاطئة والمكررة والمتزامنة وانتهاء الصلاحية واختلاف الطالب، والتحقق من عدم رفع الإتقان قبل الموعد.
|
||||||
|
- [ ] مراجعة مصدر وحقوق وصحة أسئلة بنك الامتحان وتعبئة حقول `remediation_review_*` و`remediation_source_reference` لكل سؤال معتمد. الأسئلة القديمة تبدأ `draft` ولا تصلح تلقائياً لإثبات الإتقان.
|
||||||
- [ ] ربط واجهات الواجبات المدرسية بنظام مهام وتكليفات المعلم.
|
- [ ] ربط واجهات الواجبات المدرسية بنظام مهام وتكليفات المعلم.
|
||||||
- [ ] دفع إشعارات تذكير للطالب عند استحقاق موعد اختبار التثبيت (اليوم السابع).
|
- [ ] دفع إشعارات تذكير للطالب عند استحقاق موعد اختبار التثبيت (اليوم السابع).
|
||||||
|
|
||||||
@@ -186,8 +194,10 @@
|
|||||||
- لوحات الإدارة والسوبر أدمن تقرأ إحصاءات قاعدة البيانات الحقيقية.
|
- لوحات الإدارة والسوبر أدمن تقرأ إحصاءات قاعدة البيانات الحقيقية.
|
||||||
- تشفير الرقم الوطني بـ AES-256-GCM مع فهرسة عمياء بـ HMAC.
|
- تشفير الرقم الوطني بـ AES-256-GCM مع فهرسة عمياء بـ HMAC.
|
||||||
- عزل الصلاحيات عبر الـ Middlewares لكل دور.
|
- عزل الصلاحيات عبر الـ Middlewares لكل دور.
|
||||||
|
- فحص الحصة يرفض نقل صف الطالب أو إنشاء تصريح مدرسي أثناء القراءة؛ الدورة ذات `school_id` لا تُفتح إلا لطالب المدرسة نفسها مع `school_rosters.is_claimed=1`، ولا يجيز تصريح `school_included` قديم الوصول لدورة سوق عامة. مسارا حالة ملف الطالب وتغيير صفه يشترطان دور الطالب، ولا يغير طالب المدرسة صفه من الواجهة.
|
||||||
* **شو تبقى منها بدقة:**
|
* **شو تبقى منها بدقة:**
|
||||||
- [ ] بناء آلية استيراد شعب المدارس وقوائم الطلبة من ملفات Excel/CSV.
|
- [ ] بناء آلية استيراد شعب المدارس وقوائم الطلبة من ملفات Excel/CSV.
|
||||||
|
- [ ] تدقيق مصدر كشوف الانتساب الحالية وربط حقوق الدورات بعقد مدرسي صريح؛ وجود `courses.school_id` وحده ليس دليلاً تعاقدياً شاملاً.
|
||||||
- [ ] اختبارات اختراق وعزل (IDOR multi-tenant isolation) لضمان عدم تسريب أي بيانات بين المدارس.
|
- [ ] اختبارات اختراق وعزل (IDOR multi-tenant isolation) لضمان عدم تسريب أي بيانات بين المدارس.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -207,8 +217,111 @@
|
|||||||
|
|
||||||
## 3. الخطوات المباشرة للتنفيذ الآن (Action Plan)
|
## 3. الخطوات المباشرة للتنفيذ الآن (Action Plan)
|
||||||
|
|
||||||
1. **الخطوة الأولى (تقني) — [تم الإنجاز]:** ربط خطوة التحقق المسبق (Preflight) وتنبيه النسخة المرشحة في استوديو المعلم داخل `apps/teacher_app`.
|
1. **الخطوة الأولى (تقني) — [منفذة في الكود، غير متحققة E2E]:** ربط خطوة التحقق المسبق (Preflight) وتنبيه النسخة المرشحة في استوديو المعلم داخل `apps/teacher_app`.
|
||||||
2. **الخطوة الثانية (تعليمي) — [تم الإنجاز]:** تطبيق خوارزمية التكرار المتباعد (المرحلتين: المعالجة الأولية + اختبار الاسترجاع والتثبيت بعد 7 أيام) في الخادم والواجهة.
|
2. **الخطوة الثانية (تعليمي) — [منفذة في الكود، غير متحققة تشغيلية]:** تطبيق خوارزمية التكرار المتباعد (المرحلتين: المعالجة الأولية + اختبار الاسترجاع والتثبيت بعد 7 أيام) في الخادم والواجهة، مع إعادة تحميل النتيجة من الخادم.
|
||||||
3. **الخطوة الثالثة (محتوى) — [التركيز الحالي]:** فحص واعتماد حزمة الـ 12 درساً النموذجية للصف العاشر (`publish_grade10_bundle.php --pilot`) والتحقق من سلامة الأصول والربط.
|
3. **الخطوة الثالثة (محتوى) — [التركيز الحالي]:** فحص واعتماد حزمة الـ 12 درساً النموذجية للصف العاشر (`publish_grade10_bundle.php --pilot`) والتحقق من سلامة الأصول والربط.
|
||||||
|
|
||||||
|
### 2026-09-20 — تحديث التطبيق والتحقق من مساري الاستبدال ودفتر الأخطاء
|
||||||
|
|
||||||
|
- **NP-03 / استوديو المعلم:** اكتمل ربط نتيجة `preflight` في `TeacherStudioCubit` مع حالة النسخة المرشحة ورقمها، وأضيف تنبيه صريح في `teacher_studio_upload_tab.dart` بأن الحصة المنشورة الحالية تبقى متاحة حتى اجتياز الفحص. هذا يثبت تكامل الواجهة مع مسار الاستبدال المرشح، ولا يثبت بعد رفع فيديو فعلي أو فحصه من البداية للنهاية.
|
||||||
|
- **NP-11 / دفتر الأخطاء:** أصبح نجاح المسار العلاجي يعيد تحميل البيانات من الخادم بدلاً من تعديل الحالة محلياً بشكل تفاؤلي، وأضيفت حالة `in_remediation` وعدادها ومرشحها ومسار اختبار الاسترجاع بعد 7 أيام في الواجهة، بما يتوافق مع انتقالات الخادم إلى `mastered`. لا يثبت ذلك بعد إرسال تذكير فعلي عند الاستحقاق أو ربط تكليفات المدرسة.
|
||||||
|
- **المرجع:** commit `e8163fe` بتاريخ 2026-09-20، وشمل `ErrorNotebookController.php` وتطبيق الطالب وتطبيق المعلم وملفات دفتر السجل.
|
||||||
|
- **فحوص هذه الدفعة:** لا يوجد في سجل commit دليل على تشغيل فحص جديد بعد التغييرات. تبقى آخر فحوص موثقة هي PHP lint و`git diff --check` وFlutter analyze المذكورة في إدخالات 9 و16 أيلول؛ لا نعدّ هذا التحديث متحققاً بتكامل قاعدة بيانات أو E2E.
|
||||||
|
- **الحالة بعد التحديث:** لم تتغير بوابة الإطلاق. ما زال نشر حزمة الـ12 درساً، تطبيق migrations على staging، اختبارات MySQL/Redis، التفريغ الصوتي والأدلة المرئية، الصوت الإنجليزي المعتمد، سياسة الدخل ومزود الدفع، وتجربة المستخدمين الفعلية غير مثبتة.
|
||||||
|
|
||||||
|
### 2026-09-23 — تصحيح صدق نتيجة دفتر الأخطاء (NP-11)
|
||||||
|
|
||||||
|
- كشف التدقيق أن طلب `resolve` السابق كان يقبل إثبات النجاح من الهاتف دون الإجابات، وأن أسئلة عامة مؤلفة كانت تظهر إذا نقص بنك الأسئلة؛ كما لم يكن حقل موعد الاسترجاع موجوداً في المخطط. لذلك أعيدت حالة NP-11 من `verified_in_code` إلى `implemented_unverified`.
|
||||||
|
- أضيفت migration لمحاولات العلاج وموعد الاسترجاع وحقول المراجعة الأكاديمية. بدء المحاولة أصبح POST؛ يسحب ثلاثة أسئلة منشورة ومراجعة مرتبطة بنفس `lesson_id` و`topic_tag` ويخزن مفتاح الإجابة في الخادم. إن لم تكفِ الأسئلة يعرض عدم التوفر. الإرسال يتضمن خيارات الإجابات، والتصحيح والانتقال بين الحالات يحدثان داخل transaction مع قفل صف الفجوة والمحاولة، وصلاحية 30 دقيقة ومحاولة استعمال واحدة، وإعادة نفس الإرسال تعيد النتيجة دون زيادة العدادات.
|
||||||
|
- تطبيق الطالب لم يعد يستقبل `correct_index` ولا يرفع إتقاناً بناءً على تصحيح محلي. عند تعذر تحميل الدفتر يعرض خطأ وإعادة محاولة بدلاً من نسبة إتقان 100% مصطنعة. نتيجة اختبار الاسترجاع لا تدّعي إثبات الذاكرة طويلة المدى.
|
||||||
|
- **فحوص:** `php -l` للمتحكم والراوتر، اختبار محلي خالص `test_remediation_attempt_grader.php` (9 حالات)، `dart analyze` للملفات الثلاثة المعدلة (صفر أخطاء، 14 ملاحظة `withOpacity` موجودة سابقاً)، و`git diff --check`. **غير منفذ:** migration واختبار MySQL/HTTP حقيقي لغياب بيئة staging معزولة مثبتة؛ لم تُعدّل بيانات تشغيلية.
|
||||||
|
- **التالي:** طبّق migration واختبر حالات الفشل والتزامن على staging، ثم راجع واعتمد مجموعة أسئلة الدرس التجريبي قبل قياس الإتقان في تجربة الصف العاشر.
|
||||||
|
|
||||||
|
### 2026-09-23 — إغلاق اختصار اعتماد حزمة الصف العاشر (NP-02)
|
||||||
|
|
||||||
|
- كشف التدقيق أن `publish_grade10_bundle.php --apply` كان يرفع `source_status` و`review_status` و`rights_status` آلياً دون دليل مراجعة أو حقوق. عُدّل السكربت ليعرض أسباب المنع في dry-run، ويقتصر التنفيذ على تحويل حزمة مراجعة مستوفية إلى منشورة. يتطلب مراجعاً وتاريخاً للدرس، وأصلاً رئيسياً، واعتماداً وحقوقاً مصفاة ومصدرًا لكل أصل. سحب الحزمة لا يمحو هذه القرارات.
|
||||||
|
- صحح اختيار الـpilot ليشمل 12 ملفات دروس فعلية بدلاً من إدراج `intro_and_project.md` ضمن عدد الدروس. بوابة الملفات المحلية `validate_grade10_candidate.php --json --manifest=manifest.accepted.json` أعادت `passed` مع 0 أخطاء و581 تحذيراً على 369 مدخلاً؛ هذا فحص بنيوي وليس موافقة أكاديمية.
|
||||||
|
- **فحوص:** PHP lint و`git diff --check` لملفات التغيير. **غير منفذ:** dry-run المعتمد على MySQL، تطبيق migration، مراجعة بشرية، أو نشر حي. تبقى حزمة الـ12 غير معتمدة في هذا السجل.
|
||||||
|
|
||||||
|
### 2026-09-23 — متابعة بوابات الصدق للمحتوى والعلاج
|
||||||
|
|
||||||
|
- أضيف `RemediationAttemptGrader` لاختبار ربط كل إجابة بسؤالها وخياراتها المخزنة؛ فحص محلي خالص نجح في 9 حالات صحيحة وخاطئة ومدخلات غير صالحة. أضيفت حقول مراجعة بشرية لأسئلة العلاج، وتبدأ الأسئلة القديمة `draft` فلا تُستخدم لإثبات الإتقان قبل مراجعتها.
|
||||||
|
- أزيل اختصار آخر في `publish_grade10_textbooks.php`: لم يعد ينشئ حزمة منشورة أو أصل PDF معتمداً أو يصفّي الحقوق تلقائياً، ولا ينقل كتاباً إلى فصل مختلف عند غياب المطابقة. يربط فقط PDF طالب له أصل محلي معتمد وحقوق مصفاة وبصمة ملف مطابقة وحزمة منشورة في المبحث والفصل الصحيحين؛ المعاينة تعرض العوائق وتمنع `--apply` عند وجودها.
|
||||||
|
- **الفحوص المحلية:** PHP lint للسكربت والخدمة والمتحكم، 9 حالات للمصحح، و`git diff --check`. **غير مختبر:** سلوك MySQL والتزامن وربط PDF على staging؛ ملفات PDF وموافقات حقوقها تحتاج فحصاً بشرياً.
|
||||||
|
|
||||||
|
### 2026-09-23 — إغلاق تجاوز النشر وحماية النسخة الحالية (NP-03/04)
|
||||||
|
|
||||||
|
- أُلغي مسار `SuperAdminController::reviewSubmission` القديم الذي كان يغيّر حالة الفيديو إلى `published` دون job فحص؛ يعيد الآن 410 ويوجه إلى مسار الأدلة والقرار والنشر المنفصل. صُحح استعلام قائمة الحزم لحقول مخطط المنهج الفعلية.
|
||||||
|
- أضيف `VideoCoverageValidator`: يرفض التغطية المقطوعة، والفجوات الزمنية، وتعارض بصمة الفيديو أو نقص مراجع التفريغ والصورة. تقيد `VideoReviewService` الأدلة ببصمة الفيديو وMarkdown والمدة الفعلية، ثم تعيد فحص المصدر والنسخة والحزمة المنشورة وقرار المراجع قبل تبديل النسخة الحالية.
|
||||||
|
- أبقيت `teacher_submissions.status='published'` عند رفع بديلة أو مراجعتها إذا كان لها فيديو حالي؛ ونُقل إنشاء job إلى معاملة إرفاق الفيديو بحيث يفشل معها الرفع كله بدلاً من ترك نسخة `review` بلا مهمة. الاستبدال يتطلب وجود نسخة منشورة حالية.
|
||||||
|
- **فحوص:** 8 حالات محلية خالصة للتحقق الزمني، وPHP lint و`git diff --check`. **غير منفذ:** اختبار MySQL/HTTP وتزامن فعلي، تفريغ فيديو حقيقي، مراجعة أكاديمية أو نشر.
|
||||||
|
|
||||||
|
### 2026-09-23 — مراجعة أعمق لمسارات الفيديو القديمة
|
||||||
|
|
||||||
|
- لم يعد `VideoController::getPlaybackData` يحذف امتحانات أثناء GET أو يرسل أسئلة قديمة مع `is_correct` قبل إجابة الطالب. تظل قائمة الأسئلة في المسار القديم فارغة إلى حين نشر أسئلة مربوطة بنسخة الفيديو ودليلها.
|
||||||
|
- حفظ سؤال المعلم عبر `saveCheckpoint` أصبح مسودة غير منشورة (`is_published=0`) مع تحقق من الخيارات وتوقيت الفيديو ومعاملة قاعدة بيانات. يحتاج السؤال مسار ربط أدلة ومراجعة قبل النشر.
|
||||||
|
- أضيف فحص تقرير المراجعة: يرفض أقساماً/أهدافاً ناقصة أو مشكلات حرجة/كبرى بلا حل موثق. نجحت 8 حالات محلية لهذا الفحص، إلى جانب 8 حالات تغطية الفيديو و9 حالات تصحيح العلاج. هذا لا يثبت صحة الفيديو علمياً أو تكامل قاعدة البيانات.
|
||||||
|
- **باقٍ في NP-04:** عامل تفريغ الصوت والصور، إثبات مراجع الأدلة مقابل الملفات الفعلية، مراجعة بشرية للدرس والأسئلة، اختبار تزامن النشر على MySQL، وواجهة تقرير المعلم.
|
||||||
|
|
||||||
|
### 2026-09-23 — ضبط تشغيل وبث النسخ المنشورة (NP-04/05/07)
|
||||||
|
|
||||||
|
- مسارا التشغيل القديمان المعتمدان على العنوان أو رقم `lessons` يعيدان 410؛ التشغيل المعتمد يبقى بمعرّف نسخة الفيديو المنشورة. أضيف تحقق صلاحية الطالب والنسخة المنشورة والمصدر المنهجي قبل إرسال الفيديو المحلي أو مقطع HLS، مع `Cache-Control: private, no-store` للملفات المحلية. يمكن للمعلم صاحب الدورة معاينة رفعه، وللسوبر أدمن صلاحية الإدارة.
|
||||||
|
- قائمة حصص الدرس وتشغيل النسخة وبدء جلسة المشاهدة تشترط job مراجعة معتمد وأصل Markdown مصرحاً بحقوقه داخل حزمة منشورة، وتغلق جلسة مشاهدة قائمة عند سحب الصلاحية قبل إضافة أي فترة جديدة.
|
||||||
|
- **لم يُتحقق تشغيلها بعد:** تشغيل HLS على جهاز حقيقي، صلاحيات المدرسة مقابل السوق، أحمال 100+ معلم، أو تزامن سحب أصل مع مشاهدة جارية. NP-05 لا تُعد مكتملة دون اختبار القائمة والصفحات على MySQL.
|
||||||
|
|
||||||
|
### 2026-09-23 — إصلاح صفحات قائمة المعلمين (NP-05)
|
||||||
|
|
||||||
|
- أزيل سقف 101 قبل فحص الوصول؛ استعلام المرشحين يعمل بدفعات 100 ضمن snapshot للطلب، ثم يعدّ الحصص المصرّح بها ويعيد الصفحة المطلوبة فقط. ترتيب التعادل يعتمد معرّف نسخة الفيديو، و`EXISTS` يحول دون تكرار الصف عند تعدد سجلات النشر أو المراجعة.
|
||||||
|
- تطبيق الطالب يطلب أول 20 حصة، يفتح الوحيدة مباشرة وفق `available_count`، ويتيح تحميل الدفعات التالية. أُزيل الفرز المحلي والوصف التعليمي المصطنع لأسلوب المعلم. لم يعد غياب علامة `hasVideo` القديمة يمنع الاستعلام عن درس له هوية منهجية.
|
||||||
|
- **فحوص منفذة:** PHP lint للمتحكم، `dart analyze` للملفات الثلاثة المعدلة (لا مشكلات)، و`git diff --check`. **غير منفذ:** اختبار قاعدة بيانات أو واجهة/شبكة فعلية؛ تبقى تكلفة المسح الكاملة لكل طلب بحاجة قياس عند كثافة المعلمين، وتبدل القائمة بين الطلبات يحتاج تحديثاً عند التفاعل.
|
||||||
|
|
||||||
|
### 2026-09-23 — تدقيق حجز رصيد المعلم (NP-06)
|
||||||
|
|
||||||
|
- كشف التدقيق أن معاملة `placeWithdrawalHold` القديمة لم تُسلسل الحجوزات لمعلم واحد: كان كل طلب يستطيع قراءة الرصيد قبل إدخال حجز الآخر. أضيف قفل `teachers ... FOR UPDATE` قبل أي قراءة ثابتة، مع إبقاء مفتاح الإعادة ضمن المعاملة نفسها. لا يُفتح مسار سحب حقيقي بهذا التعديل.
|
||||||
|
- `balance()` أصبحت قراءة صرفة ولا تنشئ حساباً؛ الحساب غير الموجود يساوي صفراً غير مستحق للسحب. أصلح طرح `BIGINT UNSIGNED` بصيغة `CAST(... AS SIGNED)`. سبب تحرير الحجز سيُحفظ بعد migration الجديدة؛ السجلات السابقة لا تُنسب لها أسباب مفترضة.
|
||||||
|
- **فحوص منفذة:** 9 حالات SQLite محلية معزولة لقراءة الرصيد الصرفة، صافي المدين/الدائن وخصم حجز موجود، حفظ سبب التحرير، منع التحرير لمعلم آخر، وتكراره؛ PHP lint و`git diff --check`. **غير منفذ:** إنشاء حجز موجب على MySQL staging واختبارات تزامن/فشل/استعادة؛ متغيرات قاعدة البيانات وأدوات MySQL/Docker غير متاحة في بيئة العمل الحالية. SQLite لا يثبت سلوك `FOR UPDATE`. لا تُعد NP-06 مقبولة قبل هذه الاختبارات وتسوية الأرصدة ودورة دفع مثبتة.
|
||||||
|
|
||||||
|
### 2026-09-23 — فصل شجرة التحضير عن فهرس الطالب المنشور (NP-01/02)
|
||||||
|
|
||||||
|
- أصبح `/api/curriculum/tree` عاماً للطالب لكنه يعيد فقط الدروس المطابقة بهويتها ومسارها إلى سجل منهجي معتمد وحزمة منشورة وأصل Markdown مراجع ومصرح بحقوقه. لا يمرر ملف manifest أو نتاجاته الأولية أو موارده غير المنشورة، ولا يعيد manifest عند خطأ قاعدة البيانات؛ يظهر `503` صريحاً. الفراغ المنشور يعود بكائن `{}`، لا مصفوفة ملتبسة.
|
||||||
|
- نُقل استعمال المعلم لفهرس التحضير إلى `/api/teacher/curriculum/intake-tree` خلف صلاحية المعلم، لتظل الدروس غير المنشورة قابلة للاختيار في سير التحضير دون إيهام الطالب بأنها جاهزة. مواد PDF وأوراق العمل في الشجرة العامة تأتي فقط من أصول ذات مراجعة وحقوق وحزمة منشورة، مع قيد الصف والمبحث معاً.
|
||||||
|
- لم تعد واجهة الطالب تعرض كل الصفوف K12 عند خلو النتيجة، ولا تحول الاستجابة المعطوبة إلى «لا محتوى». عُدلت حالة الفراغ والبحث لتصف ما ثبت فقط؛ قراءة فهرس التخزين لم تعد تنشئ ملف manifest أثناء GET.
|
||||||
|
- **فحوص منفذة:** 11 حالة خالصة لترشيح شجرة النشر (درسان بنفس المعرف/المسار بصفين، مورد غير منشور، درس غير منشور، تطابق هوية ونسخة المنهج وUUID الأصل)، PHP lint، `dart analyze` للملفات المعدلة، و`git diff --check`. **غير منفذ:** HTTP/MySQL على staging، فتح كتاب PDF فعلي، وحقوق/دقة المنهج الأكاديمية.
|
||||||
|
|
||||||
|
### 2026-09-23 — سد مسار ملف الدرس الخام وضبط خدمة الأصل (NP-01/02)
|
||||||
|
|
||||||
|
- قُصر `/api/curriculum/lesson` و`/api/curriculum/document` على إدارة المنهج؛ أزيلت محاولة الطالب جلب `file` الخام. ترسل شجرة النشر UUID أصل Markdown الأساسي، وتستخدمه واجهة «قراءة الدرس». غياب الملف لم يعد يعيد فقرة «محتوى معتمد» ثابتة.
|
||||||
|
- خدمة الأصل المنشور تتحقق من صف الطالب المسجل ومن أن الملف المحلّي هو التخزين المحدد ضمن جذر المنهج، وتطابق الحجم وSHA-256 قبل الإرسال، مع ترويسات منع التخزين المؤقت والـMIME sniffing. لم يعد البحث عن ملف بديل في مجلدات أخرى أو تحت جذر المشروع.
|
||||||
|
- حفظ مسودة Markdown يرفض المسار المتجاوز للجذر أو الكتابة على أصل مربوط بحزمة منشورة، ولم يعد يحدّث صفوف `lessons` ببحث `LIKE` قد يطال درساً آخر. رسالة الحفظ تصف «مسودة»، لا اعتماداً أكاديمياً.
|
||||||
|
- أُزيل JWT من رابط PDF الخارجي: بعد مصادقة الطالب يصدر الخادم تذكرة عشوائية محفوظة في Redis لمدة 120 ثانية، مقيدة بمعرّف أصل PDF والطالب. مسار التنزيل يعيد التحقق من نشر الأصل وسلامته وصف الطالب وحالة هويته عند كل طلب. التذكرة قابلة لإعادة الطلب أثناء عمرها القصير للسماح بطلبات قارئ PDF، وليست بديلاً عن جلسة عامة.
|
||||||
|
- **فحوص منفذة:** 10 حالات محلية معزولة للوصول/الملف والمسار، 11 حالة لترشيح الشجرة بعد إضافة UUID الأصل، PHP lint، `dart analyze`، و`git diff --check`. **غير منفذ:** اختبار HTTP/MySQL/Redis، انتهاء التذكرة أو محاولة استعمالها لأصل آخر على خادم فعلي، سحب أصل أثناء طلب جارٍ، واختبار PDF على جهاز.
|
||||||
|
- **فجوة أمن/تجربة مفتوحة:** رابط PDF يحمل الآن تذكرة قصيرة ومقيدة لا JWT، لكن نجاح عرض الصفحات المتعددة عبر المتصفح الخارجي لم يُختبر. لا يحمل مخطط الأصل نطاق مدرسة/tenant، لذلك تحقق الصف لا يثبت عزل أصول مدرسة عن أخرى. النسخة المحلية المحفوظة تحتاج سياسة انتهاء/سحب موثقة قبل اعتبارها cache إنتاجياً آمناً.
|
||||||
|
|
||||||
|
### 2026-09-23 — ضبط وصول المدرسة والسوق وملف الطالب (NP-05/12)
|
||||||
|
|
||||||
|
- أزيلت آلية `validateLessonAccess` التي كانت تحوّل صف الطالب المستقل تلقائياً عند قراءة حصة وتمنح الطالب المدرسي أي دورة مجاناً وتُنشئ `school_included` pass أثناء الطلب. صار الفحص قراءة صرفة: تطابق الصف، دورة منشورة، تطابق الدرس مع الدورة، وحجب الدورة المدرسية عن مدرسة أخرى أو مستقل. وصول طالب المدرسة لدورة مدرسته يتطلب انتساباً مؤكداً في `school_rosters`؛ الدورة العامة المدفوعة تتطلب تصريح سوق نشطاً، ولا يكفي `school_included` قديم.
|
||||||
|
- `POST /api/student/profile/update-grade` و`GET /api/student/profile/status` خلف دور الطالب. تغيير الصف يرفض طالب المدرسة والصف/المسار غير الصالح؛ قراءة الملف لا تكتب تصحيح صف تلقائي. أُغلق فرع تحديث ملف قديم كان يقبل JWT مباشرة لتغيير الرقم الوطني دون إثبات الهوية والجلسة النشطة؛ إكمال التسجيل الجديد يظل عبر `identity_token` المخصص.
|
||||||
|
- **فحوص منفذة:** 12 حالة SQLite محلية معزولة تشمل طالباً مستقلاً، مدرستين، كشفاً غير مؤكد، دورة عامة مجانية ومدفوعة، تصريحاً مدرسياً قديماً، صفاً خاطئاً، ودورة غير منشورة، مع التحقق من عدم تغير الصف أو عدد التصاريح. PHP lint و`git diff --check`.
|
||||||
|
- **غير منفذ:** MySQL/HTTP مع مدرستين وعقود حقيقية، تسوية التصاريح القديمة، تدقيق منافذ المعاينة، وإثبات أن كل دورة `school_id` مصرح بها تعاقدياً لتلك المدرسة. نتيجة الفحص المحلي لا تُعد إثبات عزل شامل للمنصة.
|
||||||
|
|
||||||
|
### 2026-09-29 — تجربة الطالب على الخادم المباشر (NP-01/02/04)
|
||||||
|
|
||||||
|
- شُغّل تطبيق الطالب على macOS واتصل بـ`saqel.intaleqapp.com`؛ أعاد API للمناهج 13 مبحثاً منشوراً. السبب الجذري لرابط التشغيل الخاطئ كان سكربت `link_legacy_math_videos.php`: يحقن مضيف `b-cdn.net` ويعلّم نسخاً منشورة من دون مهام مراجعة. السجلان الحاليان للدرس 1 و2 من هذا المصدر أعادا HTTP 403. ملفات HLS المقابلة على Cloudflare R2 تعيد HTTP 200، ومددها 778 و930 ثانية، ومفتاحا المنهج وعناوين الدرسين متطابقان مع الملفين المحليين على الخادم. لا توجد لهما مهام/أدلة مراجعة معتمدة.
|
||||||
|
- أُصلح إصدار تذاكر تنزيل PDF في الخادم: أضيف مسار POST لإصدار ticket وGET لتنزيل الأصل، خدمة Redis قصيرة العمر ومقيدة بالأصل والطالب، وتحقق صلاحية الصف وبصمة الملف في خدمة المحتوى. عولجت فجوة التوافق مع نسخة `PublishedContentService` المنشورة، ثم تحققت مسارات HTTP كـ401/403 دون تسجيل دخول ونجح إصدار تذكرة من التطبيق. عناوين الكتب أصبحت تميز الملفات المعروفة وتصرح بالفصل/الوحدة عند نقص اسم المصدر.
|
||||||
|
- أُنشئت نسختان بديلتان عبر `TeacherSubmissionService` الرسمي، ببصمة MP4 المطابقة للملفين على الخادم، وحُفظت حالتهما `review` مع `needs_evidence`. بقيت النسختان الحاليتان كما هما إلى أن تجتاز البدائل المراجعة. المدخلات الناقصة لكل مرشح: تفريغ مؤقت كامل، دليل بصري، وبيان تغطية. أُوقفت أداة الربط القديمة محلياً وعلى الخادم، كي لا تعيد نشر روابط Bunny أو تتجاوز الفحص.
|
||||||
|
- شُددت API الإنتاج: الشجرة وقائمة الحصص وendpoint التشغيل لا تعتبر الفيديو منشوراً إلا بعد موافقة مراجعة الفيديو وربطه بحزمة Markdown المنشورة وبصمتها. فذلك يحجب الإصدارات القديمة التي لا تملك أي سجل مراجعة، ويمنع اعتبار المرشحين الجدد منشورين قبل اكتمال الدليل. إعداد R2 يُحمّل من ملف البيئة الخارجي `/home/intaleqapp-saqel/.env` عبر bootstrap الإنتاج؛ فحص آمن للتهيئة أكد وجود account/access/secret/endpoint/bucket/public URL من دون طباعة قيمها. ملف `backend/.env` المحلي ليس مصدر إعداد الإنتاج.
|
||||||
|
- **الفحوص المنفذة:** بناء وتشغيل macOS debug، تحقق واجهة فشل التشغيل عبر CUA، `flutter analyze` للملفات السبعة (صفر errors؛ 20 info style diagnostics)، PHP lint لملفات التغيير، `git diff --check`، طلبات HTTP لقوائم HLS R2، وتسجيل المرشحين من خلال الخدمة الرسمية والتحقق من SHA256 وحالة مهمة الفحص.
|
||||||
|
- **المتبقي:** لا يمكن نشر المرشحين من دون الأدلة الزمنية والبصرية ومراجع أكاديمية بشرية؛ لم تُختلق هذه الأدلة. بعد مراجعتها، يلزم قرار اعتماد ثم نشر النسخة البديلة، وبعدها اختبار تشغيلها على الماك. اختبار فتح صفحات PDF متعددة وتدفق انتهاء تذكرة التنزيل يحتاجان تحققاً إضافياً على جهاز الطالب. تغييرات Flutter محلية ولم تُوزع كتطبيق إنتاج؛ تغييرات API وإيقاف السكربت نُشرت على الخادم.
|
||||||
|
|
||||||
|
### 2026-09-30 — استكمال فحص الطالب على macOS (NP-01/02/04/05/11)
|
||||||
|
|
||||||
|
- **بيئة الفحص:** macOS 26.0 على Apple Silicon، تشغيل `flutter run -d macos --no-pub` بالنسخة المحلية على API الإنتاج، بحساب طالب صف عاشر موجود مسبقاً على الجهاز. لا توجد كتابة في قاعدة الإنتاج أثناء هذا الفحص.
|
||||||
|
- **نتائج مرئية:** `/api/auth/me` أعاد 200؛ `/api/student/lessons` أعاد 200 وقائمة فارغة؛ `/api/curriculum/tree` أعاد 13 مبحثاً. واجهة الرياضيات تعرض دروس manifest إضافية لا تتوفر لها حصص، وبعض صفوف الدرس بلا `primary_lesson_asset_id`. فتح قارئ PDF لهذه الحالة أظهر «المورد لا يحمل معرّف أصل منشوراً صالحاً» وصفحة فارغة. هذا خلل عقد API/بيانات منشورة، وليس عطلاً في R2 أو عارض PDF نفسه.
|
||||||
|
- **نزاهة المختبرات:** الفحص وجد `previewModeEnabled=true` افتراضياً ومسار ربط بالعنوان/مفتاح الدرس، وأدى فعلياً إلى اقتراح مختبر «أوتار الدائرة والقطاعات» لدرس «حل معادلات خاصة». أُغلقت المعاينة في تطبيق الطالب؛ صار فتح مختبر الدرس يتطلب UUID منهجي مطابقاً ومختبراً يحمل إصدار حزمة منشورة، واختفى fallback الذي كان يفتح الأداة التخصصية عند خلو قائمة المختبرات.
|
||||||
|
- **إصلاح محلي غير منشور:** عقد `GET /api/curriculum/tree` المحلي صار يربط صفوف intake بمخرجات حزم منشورة وأصل Markdown معتمد ومصفّى الحقوق ويعيد `primary_lesson_asset_id`. استعلام `has_video` يطلب مراجعة معتمدة وبصمة Markdown مطابقة. الإنتاج ما زال على نسخة controller أقدم لا ترسل معرّف الأصل، وملف `PublishedCurriculumTreeFilter.php` غير موجود فيه؛ لا يجوز تبديل route إلى service الجديدة وحدها. هذا التعديل لم يُنشر بعد، ولا تعني بيانات الدرس المحلية أنها اجتازت المراجعة أو أن PDF كتاباً متاحاً.
|
||||||
|
- **الفحوص المنفذة:** `flutter run` macOS ناجح؛ مشي UI حتى رياضيات/درس/PDF وملاحظة الخطأ؛ `dart analyze` للملفات الأربع (صفر errors، ملاحظة lint قديمة واحدة)؛ PHP lint؛ `php backend/scripts/test_published_curriculum_tree_filter.php` نجح بـ11 check؛ `git diff --check`.
|
||||||
|
- **ترتيب المتابعة:** (1) تصحيح عقد `/api/curriculum/tree` على staging ثم نشره بعد التحقق، كي لا يعرض إلا دروس الحزم المنشورة ويرسل UUID الأصل. (2) نشر أو ربط مصادر PDF حقيقية بمراجعة وحقوق موثقتين ثم اختبار تذكرة التنزيل وفتح صفحات متعددة على macOS. (3) توفير أدلة مراجعة الفيديو للمرشحين R2 ثم مراجعة بشرية ونشر إصدار؛ بعده اختبار اختيار معلم واحد/متعدد وتشغيل/استئناف المشاهدة. (4) معالجة `/api/student/lessons` الفارغ: تقرير سليم بعد إزالة legacy بلا حصص معتمدة، لكنه يجعل لوحة اليوم خالية؛ يجب ربط كتالوج المنهج الجديد بالتطبيق أو تقديم تجربة واضحة للفهرس المنشور. (5) مراجعة مسارات الاختبار وبنك الأسئلة ودفتر الأخطاء والتحميل دون ادعاء إتقان أو نجاح عند غياب مصادر معتمدة.
|
||||||
|
- **غير مفحوص في هذه الجولة:** PDF قابل للتنزيل/متعدد الصفحات (الأصل غير متوفر في الاستجابة)، قائمة فيديو بعد المصادقة، تشغيل HLS داخل التطبيق بعد اعتماد نسخة، الاختبارات والدفتر والتقدم، قارئ الشاشة/VoiceOver، بناء release موقّع، تطبيق المعلم/الإدارة على macOS.
|
||||||
|
- **مسار Git والنشر:** فُحص مستودع الماك والسيرفر؛ كلاهما `main` عند `e8163fe`. نُفذ `git pull --ff-only origin main` على السيرفر بحساب مالك المستودع بعد رفض الكتابة بحساب SSH على `.git/FETCH_HEAD`؛ النتيجة `Already up to date`. لم يكن على origin commit أحدث كي يصلح الفهرس أو التطبيق. بقيت تعديلات وملفات تشغيل غير متتبعة موجودة على السيرفر، ولم تُمسح أو تُستبدل. تغييرات الماك الحالية غير مرفوعة؛ لذلك build الماك لا يحدّث الإنتاج وحده.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user