diff --git a/apps/super_admin_app/lib/data/repositories/super_admin_repository.dart b/apps/super_admin_app/lib/data/repositories/super_admin_repository.dart index 7c64968..f480256 100644 --- a/apps/super_admin_app/lib/data/repositories/super_admin_repository.dart +++ b/apps/super_admin_app/lib/data/repositories/super_admin_repository.dart @@ -75,6 +75,28 @@ class SuperAdminRepository { return _decode(response)['data']; } + Future _post(String path, Map body) async { + final response = await http.post(Uri.parse('${AppConfig.apiBaseUrl}$path'), headers: { + ...await _headers(), 'Content-Type': 'application/json', + }, body: json.encode(body)).timeout(const Duration(seconds: 20)); + return _decode(response)['data']; + } + + Future>> getSchools() async => + (await _get('/api/super-admin/schools') as List).map((e) => Map.from(e as Map)).toList(); + + Future>> getStaff() async => + (await _get('/api/super-admin/staff') as List).map((e) => Map.from(e as Map)).toList(); + + Future saveSchool(Map body) async { await _post('/api/super-admin/schools/save', body); } + Future toggleSchool(int id) async { await _post('/api/super-admin/schools/toggle', {'id': id}); } + Future saveStaff(Map body) async { await _post('/api/super-admin/staff/save', body); } + Future toggleStaff(int id) async { await _post('/api/super-admin/staff/toggle', {'id': id}); } + Future>> getDirectorates() async => + (await _get('/api/super-admin/directorates') as List).map((e) => Map.from(e as Map)).toList(); + Future saveDirectorate(Map body) async { await _post('/api/super-admin/directorates/save', body); } + Future toggleDirectorate(int id) async { await _post('/api/super-admin/directorates/toggle', {'id': id}); } + Future getMacroTelemetry() async => MacroTelemetryModel.fromJson(Map.from(await _get('/api/super-admin/overview') as Map)); diff --git a/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart b/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart index 9c4c77c..44e66cc 100644 --- a/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart +++ b/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart @@ -9,6 +9,7 @@ import 'tabs/macro_radar_tab.dart'; import 'tabs/ai_cluster_tab.dart'; import 'tabs/treasury_cliq_tab.dart'; import 'tabs/security_integrity_tab.dart'; +import 'tabs/organization_tab.dart'; /** * ============================================================================== @@ -158,6 +159,7 @@ class _SuperAdminShellState extends State { alerts: state.alerts, isKillSwitchActive: state.isEmergencyKillSwitchActive, ), + const OrganizationTab(), ], ); } @@ -201,6 +203,11 @@ class _SuperAdminShellState extends State { activeIcon: Icon(CupertinoIcons.shield_lefthalf_fill, color: SuperAdminTheme.royalGold), label: 'النزاهة والأمان', ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.building_2_fill), + activeIcon: Icon(CupertinoIcons.building_2_fill, color: SuperAdminTheme.royalGold), + label: 'الهيكل الإداري', + ), ], ), ), diff --git a/apps/super_admin_app/lib/presentation/screens/tabs/organization_tab.dart b/apps/super_admin_app/lib/presentation/screens/tabs/organization_tab.dart new file mode 100644 index 0000000..5945d87 --- /dev/null +++ b/apps/super_admin_app/lib/presentation/screens/tabs/organization_tab.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../data/repositories/super_admin_repository.dart'; +import '../../../core/theme/super_admin_theme.dart'; + +class OrganizationTab extends StatefulWidget { const OrganizationTab({super.key}); @override State createState()=>_OrganizationTabState(); } +class _OrganizationTabState extends State { + final repo=SuperAdminRepository(); final search=TextEditingController(); String role='all'; + List> schools=[], staff=[]; bool loading=true; + @override void initState(){super.initState(); _load();} + Future _load() async { setState(()=>loading=true); try { final r=await Future.wait([repo.getSchools(),repo.getStaff()]); if(mounted)setState((){schools=r[0];staff=r[1];loading=false;}); } catch(_){if(mounted)setState(()=>loading=false);}} + void _showMap(Map s) { final lat=(s['latitude'] as num?)?.toDouble()??31.9539, lng=(s['longitude'] as num?)?.toDouble()??35.9106; showDialog(context:context,builder:(_)=>AlertDialog(backgroundColor:SuperAdminTheme.surfaceDark,title:Text('${s['name']}',style:const TextStyle(color:Colors.white)),content:SizedBox(width:500,height:380,child:FlutterMap(options:MapOptions(initialCenter:LatLng(lat,lng),initialZoom:13),children:[TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',userAgentPackageName:'com.saqel.super_admin'),MarkerLayer(markers:[Marker(point:LatLng(lat,lng),width:42,height:42,child:const Icon(Icons.location_pin,color:Colors.red,size:42))])])),actions:[TextButton(onPressed:()=>Navigator.pop(context),child:const Text('إغلاق'))])); } + @override Widget build(BuildContext c){ final q=search.text.trim().toLowerCase(); final filtered=staff.where((x)=> (role=='all'||x['role']==role) && (q.isEmpty||'${x['full_name']} ${x['role']}'.toLowerCase().contains(q))).toList(); return RefreshIndicator(onRefresh:_load,child:ListView(padding:const EdgeInsets.all(16),children:[Row(children:[const Expanded(child:Text('إدارة الهيكل الإداري',style:TextStyle(color:Colors.white,fontSize:20,fontWeight:FontWeight.bold))),IconButton(onPressed:_load,icon:const Icon(Icons.refresh,color:SuperAdminTheme.cyberCyan))]),const SizedBox(height:12),TextField(controller:search,onChanged:(_)=>setState((){}),style:const TextStyle(color:Colors.white),decoration:const InputDecoration(labelText:'بحث بالاسم أو الدور',labelStyle:TextStyle(color:Colors.white60),prefixIcon:Icon(Icons.search,color:SuperAdminTheme.cyberCyan))),const SizedBox(height:8),DropdownButton(value:role,isExpanded:true,dropdownColor:SuperAdminTheme.surfaceDark,style:const TextStyle(color:Colors.white),items:const [DropdownMenuItem(value:'all',child:Text('كل الأدوار')),DropdownMenuItem(value:'super_admin',child:Text('Super Admin')),DropdownMenuItem(value:'school_admin',child:Text('مدير مدرسة')),DropdownMenuItem(value:'directorate_admin',child:Text('مدير مديرية')),DropdownMenuItem(value:'supervisor',child:Text('مشرف'))],onChanged:(v)=>setState(()=>role=v??'all')),Text('المدارس (${schools.length})',style:const TextStyle(color:SuperAdminTheme.royalGold,fontWeight:FontWeight.bold)),...schools.map((s)=>ListTile(onTap:()=>_showMap(s),leading:const Icon(Icons.location_on,color:SuperAdminTheme.cyberCyan),title:Text('${s['name']}',style:const TextStyle(color:Colors.white)),subtitle:Text('${s['code']} • ${s['is_active']==1?'نشطة':'موقوفة'} • اضغط لعرض الخريطة',style:const TextStyle(color:Colors.white60)))),const SizedBox(height:12),Text('الحسابات (${filtered.length})',style:const TextStyle(color:SuperAdminTheme.royalGold,fontWeight:FontWeight.bold)),if(loading)const Center(child:CircularProgressIndicator()) else ...filtered.map((s)=>ListTile(leading:const Icon(Icons.badge,color:SuperAdminTheme.cyberCyan),title:Text('${s['full_name']}',style:const TextStyle(color:Colors.white)),subtitle:Text('${s['role']} • ${s['status']}',style:const TextStyle(color:Colors.white60))))])); } +} diff --git a/apps/super_admin_app/pubspec.lock b/apps/super_admin_app/pubspec.lock index 5020938..2f4a2b3 100644 --- a/apps/super_admin_app/pubspec.lock +++ b/apps/super_admin_app/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" device_info_plus: dependency: "direct main" description: @@ -142,6 +150,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + url: "https://pub.dev" + source: hosted + version: "7.0.2" flutter_secure_storage: dependency: "direct main" description: @@ -272,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -304,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" + url: "https://pub.dev" + source: hosted + version: "2.8.0" logging: dependency: transitive description: @@ -336,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -432,6 +480,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: transitive description: @@ -573,6 +637,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" vector_math: dependency: transitive description: @@ -613,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.5" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/apps/super_admin_app/pubspec.yaml b/apps/super_admin_app/pubspec.yaml index 2370376..e02a7f7 100644 --- a/apps/super_admin_app/pubspec.yaml +++ b/apps/super_admin_app/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: device_info_plus: ^10.1.0 google_fonts: ^6.2.1 intl: ^0.19.0 + flutter_map: ^7.0.2 + latlong2: ^0.9.1 dev_dependencies: flutter_test: diff --git a/backend/app/Controllers/SuperAdminController.php b/backend/app/Controllers/SuperAdminController.php index e6d9a81..bc9579e 100644 --- a/backend/app/Controllers/SuperAdminController.php +++ b/backend/app/Controllers/SuperAdminController.php @@ -6,9 +6,59 @@ use App\Core\Database; use App\Core\Request; use App\Core\Response; use App\Services\CliqPaymentService; +use App\Core\Security; class SuperAdminController { + public function directorates(Request $request, Response $response): void + { $response->json(['status'=>'success','data'=>Database::select('SELECT * FROM directorates ORDER BY id DESC')]); } + + public function saveDirectorate(Request $request, Response $response): void + { $b=$request->getBody(); $id=(int)($b['id']??0); $name=trim((string)($b['name']??'')); $code=trim((string)($b['code']??'')); if($name===''||$code===''){ $response->status(422)->json(['status'=>'error','message'=>'اسم المديرية والرمز مطلوبان']); return; } if($id>0) Database::query('UPDATE directorates SET name=?,code=?,type=?,commander_name=?,phone=?,is_active=? WHERE id=?',[$name,$code,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null,(int)($b['is_active']??1),$id]); else Database::query('INSERT INTO directorates(uuid,code,name,type,commander_name,phone) VALUES(UUID(),?,?,?,?,?)',[$code,$name,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null]); $response->json(['status'=>'success']); } + + public function toggleDirectorate(Request $request, Response $response): void + { $b=$request->getBody(); Database::query("UPDATE directorates SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); } + public function schools(Request $request, Response $response): void + { + $response->json(['status' => 'success', 'data' => Database::select( + "SELECT s.*, d.name AS directorate_name FROM schools s LEFT JOIN directorates d ON d.id=s.directorate_id ORDER BY s.id DESC" + )]); + } + + public function saveSchool(Request $request, Response $response): void + { + $b = $request->getBody(); $id = (int)($b['id'] ?? 0); + $name = trim((string)($b['name'] ?? '')); $code = trim((string)($b['code'] ?? '')); + if ($name === '' || $code === '') { $response->status(422)->json(['status'=>'error','message'=>'اسم المدرسة والرمز مطلوبان']); return; } + if ($id > 0) { + Database::query("UPDATE schools SET name=?, code=?, type=?, directorate_id=?, director_name=?, phone=?, city=?, governorate=?, latitude=?, longitude=? WHERE id=?", [$name,$code,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null,$id]); + } else { + Database::query("INSERT INTO schools (uuid,code,name,type,directorate_id,director_name,phone,city,governorate,latitude,longitude) VALUES (UUID(),?,?,?,?,?,?,?,?,?,?)", [$code,$name,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null]); + $id=(int)Database::getInstance()->lastInsertId(); + } + $response->json(['status'=>'success','data'=>['id'=>$id]]); + } + + public function toggleSchool(Request $request, Response $response): void + { $b=$request->getBody(); Database::query("UPDATE schools SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); } + + public function staff(Request $request, Response $response): void + { $response->json(['status'=>'success','data'=>Database::select("SELECT sa.id,sa.uuid,sa.identity_id,sa.full_name,sa.role,sa.school_id,sa.directorate_id,sa.status,ai.phone_hash FROM staff_accounts sa JOIN auth_identities ai ON ai.id=sa.identity_id ORDER BY sa.id DESC")]); } + + public function toggleStaff(Request $request, Response $response): void + { $b=$request->getBody(); Database::query("UPDATE staff_accounts SET status=IF(status='active','suspended','active') WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); } + + public function saveStaff(Request $request, Response $response): void + { + $b=$request->getBody(); $id=(int)($b['id']??0); $role=(string)($b['role']??''); $name=trim((string)($b['full_name']??'')); $phone=preg_replace('/\D+/','',(string)($b['phone']??'')); + if($name===''||!in_array($role,['super_admin','school_admin','directorate_admin','supervisor'],true)){ $response->status(422)->json(['status'=>'error','message'=>'الاسم والدور مطلوبان']); return; } + if(str_starts_with($phone,'07'))$phone='962'.substr($phone,1); $hash=Security::blindIndex($phone); + $identity=Database::selectOne('SELECT id FROM auth_identities WHERE phone_hash=? LIMIT 1',[$hash]); + if(!$identity){ Database::query("INSERT INTO auth_identities(uuid,phone_number,phone_hash,status) VALUES(UUID(),?,?, 'active')",[Security::encrypt($phone),$hash]); $identity=['id'=>(int)Database::getInstance()->lastInsertId()]; } + if($id>0) Database::query('UPDATE staff_accounts SET full_name=?,role=?,school_id=?,directorate_id=?,status=\'active\' WHERE id=?',[$name,$role,$b['school_id']?:null,$b['directorate_id']?:null,$id]); + else Database::query("INSERT INTO staff_accounts(uuid,identity_id,full_name,role,school_id,directorate_id) VALUES(UUID(),?,?,?,?,?)",[$identity['id'],$name,$role,$b['school_id']?:null,$b['directorate_id']?:null]); + $response->json(['status'=>'success']); + } public function overview(Request $request, Response $response): void { CliqPaymentService::ensureSchema(); diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index d375fe1..484099a 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -98,6 +98,24 @@ class VideoController return; } + // No Cloudflare R2 write occurs before this fail-closed media gate. + // The report is generated from the real uploaded file while it remains + // in PHP's temporary storage. + $preflight = AiVideoAnalyzerService::auditUploadBeforeStorage( + $_FILES['video'], + $title, + trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? '')) + ); + $auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight); + if (($preflight['decision'] ?? '') !== 'approved') { + $response->status(422)->json([ + 'status' => 'needs_review', + 'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.', + 'data' => ['audit_id' => $auditId, 'preflight_report' => $preflight], + ]); + return; + } + try { $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title); @@ -116,6 +134,9 @@ class VideoController $uploadResult['duration'] ?? 0 ] ); + if ($auditId) { + Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]); + } // Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation (Silent Background Execution) $aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId); @@ -128,6 +149,8 @@ class VideoController 'title' => $title, 'course_id' => $courseId, 'ai_analysis' => $aiReport + ,'preflight_report' => $preflight, + 'audit_id' => $auditId, ]) ]); } catch (\Throwable $e) { @@ -138,6 +161,21 @@ class VideoController } } + private function recordUploadAudit(Request $request, int $courseId, array $file, array $report): ?int + { + try { + $hex = bin2hex(random_bytes(16)); + $uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20); + return (int)Database::insert( + 'INSERT INTO video_upload_audits (uuid, teacher_id, course_id, file_sha256, original_filename, decision, report_json) VALUES (?, ?, ?, ?, ?, ?, ?)', + [$uuid, (int)$request->user_id, $courseId ?: null, hash_file('sha256', (string)($file['tmp_name'] ?? '')), (string)($file['name'] ?? 'video'), (string)($report['decision'] ?? 'needs_manual_review'), json_encode($report, JSON_UNESCAPED_UNICODE)] + ); + } catch (\Throwable $e) { + error_log('Video upload audit persistence failed: ' . $e->getMessage()); + return null; + } + } + /** * Create video entity on Bunny Stream * POST /api/teacher/videos/bunny-create diff --git a/backend/app/Services/AiVideoAnalyzerService.php b/backend/app/Services/AiVideoAnalyzerService.php index 9ab5195..b94c010 100644 --- a/backend/app/Services/AiVideoAnalyzerService.php +++ b/backend/app/Services/AiVideoAnalyzerService.php @@ -21,6 +21,69 @@ use App\Core\Security; class AiVideoAnalyzerService { + /** + * Fail-closed admission gate for teacher uploads. The media stays in PHP's + * temporary upload area until Gemini and technical checks approve it. + */ + public static function auditUploadBeforeStorage(array $file, string $title, string $subject = ''): array + { + $path = (string)($file['tmp_name'] ?? ''); + if ($path === '' || !is_file($path)) { + return ['decision' => 'rejected', 'reason' => 'ملف الفيديو غير متاح للتدقيق']; + } + $mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path) ?: ''; + if (!str_starts_with($mime, 'video/')) { + return ['decision' => 'rejected', 'reason' => 'نوع الملف ليس فيديو صالحاً']; + } + $ffprobe = trim((string)@shell_exec('command -v ffprobe 2>/dev/null')); + if ($ffprobe === '') { + return ['decision' => 'needs_manual_review', 'reason' => 'خدمة فحص الوسائط غير متاحة؛ لم يتم رفع الفيديو']; + } + $raw = @shell_exec(escapeshellarg($ffprobe) . ' -v error -show_entries format=duration -show_streams -of json ' . escapeshellarg($path)); + $probe = json_decode((string)$raw, true); + $duration = (float)($probe['format']['duration'] ?? 0); + $hasVideo = false; $hasAudio = false; + foreach (($probe['streams'] ?? []) as $stream) { + $hasVideo = $hasVideo || (($stream['codec_type'] ?? '') === 'video'); + $hasAudio = $hasAudio || (($stream['codec_type'] ?? '') === 'audio'); + } + if (!$hasVideo || !$hasAudio || $duration <= 0 || $duration > 1500) { + return ['decision' => 'rejected', 'reason' => 'الفيديو يجب أن يحتوي صورة وصوتاً وأن لا يتجاوز 25 دقيقة', 'duration_seconds' => $duration]; + } + $key = trim((string)getenv('GEMINI_API_KEY')); + if ($key === '') return ['decision' => 'needs_manual_review', 'reason' => 'Gemini غير مهيأ؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration]; + + // Extract a representative frame; Gemini receives real media evidence, + // not only the title or metadata supplied by the teacher. + $frame = tempnam(sys_get_temp_dir(), 'saqel_audit_') . '.jpg'; + $ffmpeg = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null')); + if ($ffmpeg === '' || @shell_exec(escapeshellarg($ffmpeg) . ' -y -ss ' . escapeshellarg((string)max(1, (int)($duration / 3))) . ' -i ' . escapeshellarg($path) . ' -frames:v 1 -q:v 3 ' . escapeshellarg($frame) . ' 2>/dev/null') === null || !is_file($frame)) { + return ['decision' => 'needs_manual_review', 'reason' => 'تعذر استخراج لقطة للتقييم؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration]; + } + try { + $prompt = "أنت مدقق جودة تربوية لمنصة صقل. قيّم اللقطة الفعلية من فيديو تعليمي بعنوان: {$title}. المادة: {$subject}. مدة الفيديو: {$duration} ثانية. أخرج JSON فقط: {\"decision\":\"approved|needs_manual_review|rejected\",\"visual_clarity_score\":0-100,\"pedagogical_readiness_score\":0-100,\"report\":\"سبب عربي مختصر\"}. ارفض المحتوى غير التعليمي أو غير الواضح. لا تمنح الموافقة إن لم تظهر أدلة كافية."; + $payload = [ + 'contents' => [[ + 'parts' => [ + ['text' => $prompt], + ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode((string)file_get_contents($frame))],], + ], + ]], + 'generationConfig' => ['responseMimeType' => 'application/json', 'temperature' => 0.1], + ]; + $ch = curl_init('https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=' . rawurlencode($key)); + curl_setopt_array($ch, [CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>json_encode($payload),CURLOPT_HTTPHEADER=>['Content-Type: application/json'],CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>30]); + $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $text = json_decode((string)$body, true)['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $report = json_decode($text, true); + if ($code !== 200 || !is_array($report) || !in_array($report['decision'] ?? '', ['approved','needs_manual_review','rejected'], true)) throw new \RuntimeException('استجابة Gemini غير صالحة'); + $report['duration_seconds'] = $duration; + if (($report['visual_clarity_score'] ?? 0) < 70 || ($report['pedagogical_readiness_score'] ?? 0) < 70) $report['decision'] = 'needs_manual_review'; + return $report; + } catch (\Throwable $e) { + return ['decision' => 'needs_manual_review', 'reason' => 'تعذر إكمال تحليل Gemini؛ لم يتم رفع الفيديو']; + } finally { @unlink($frame); } + } /** * التحليل الآلي المستقل للفيديو وإنشاء الفصول ونقاط الفحص السقراطي في قاعدة البيانات * @@ -138,8 +201,8 @@ class AiVideoAnalyzerService { try { $geminiKey = getenv('GEMINI_API_KEY'); - $alignmentScore = 96.50; - $clarityScore = 94.00; + $alignmentScore = 0.0; + $clarityScore = 0.0; $outcomes = [ "استيعاب المفهوم الرياضي/العلمي لدرس {$lessonTitle}", "تطبيق القواعد والقوانين المعتمدة في كتاب الوزارة", @@ -151,8 +214,8 @@ class AiVideoAnalyzerService 'application' => 30, 'analysis' => 10 ]; - $critique = "الحصة التعليمية مطابقة لمعايير المنهاج الوزاري المعتمد وتغطي نتاجات التعلم الأساسية بكفاءة عالية."; - $status = 'approved_official'; + $critique = "لم يكتمل تدقيق Gemini؛ تتطلب الحصة مراجعة بشرية."; + $status = 'needs_manual_review'; if (!empty($geminiKey)) { $prompt = "أنت كبير المشرفين التربويين في وزارة التربية والتعليم الأردنية لمنصة صَقِل. @@ -241,11 +304,7 @@ class AiVideoAnalyzerService error_log("Pedagogical quality assessment error: " . $e->getMessage()); } - return [ - 'alignment_score' => 96.0, - 'clarity_score' => 94.0, - 'status' => 'approved_official' - ]; + return ['alignment_score' => 0.0, 'clarity_score' => 0.0, 'status' => 'needs_manual_review']; } /** diff --git a/backend/database_schema.sql b/backend/database_schema.sql index ccc090f..e254da5 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -478,6 +478,25 @@ CREATE TABLE IF NOT EXISTS `video_quality_assessments` ( CONSTRAINT `fk_vqa_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `video_upload_audits` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL UNIQUE, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED DEFAULT NULL, + `lesson_id` BIGINT UNSIGNED DEFAULT NULL, + `file_sha256` CHAR(64) NOT NULL, + `original_filename` VARCHAR(500) NOT NULL, + `decision` ENUM('approved', 'needs_manual_review', 'rejected') NOT NULL, + `report_json` JSON NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_video_upload_audits_teacher` (`teacher_id`), + KEY `idx_video_upload_audits_decision` (`decision`), + CONSTRAINT `fk_video_upload_audits_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_video_upload_audits_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_video_upload_audits_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ------------------------------------------------------------------------------ -- 12. Table: exams (الامتحانات وبنوك الأسئلة التكيفية: فحص سقراطي، امتحان درس، وحدة، وامتحان وزاري) -- ------------------------------------------------------------------------------ diff --git a/backend/migrations/20260908_video_upload_audits.sql b/backend/migrations/20260908_video_upload_audits.sql new file mode 100644 index 0000000..a1279a3 --- /dev/null +++ b/backend/migrations/20260908_video_upload_audits.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `video_upload_audits` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL UNIQUE, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED NULL, + `lesson_id` BIGINT UNSIGNED NULL, + `file_sha256` CHAR(64) NOT NULL, + `original_filename` VARCHAR(500) NOT NULL, + `decision` ENUM('approved', 'needs_manual_review', 'rejected') NOT NULL, + `report_json` JSON NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_video_upload_audits_teacher` (`teacher_id`), + KEY `idx_video_upload_audits_decision` (`decision`), + CONSTRAINT `fk_video_upload_audits_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_video_upload_audits_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_video_upload_audits_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/public/index.php b/backend/public/index.php index 9ed3270..88f019e 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -108,6 +108,15 @@ $router->get('/api/super-admin/overview', [\App\Controllers\SuperAdminController $router->get('/api/super-admin/payouts', [\App\Controllers\SuperAdminController::class, 'payouts'], $superAdminMiddleware); $router->get('/api/super-admin/ai-nodes', [\App\Controllers\SuperAdminController::class, 'aiNodes'], $superAdminMiddleware); $router->get('/api/super-admin/security-alerts', [\App\Controllers\SuperAdminController::class, 'securityAlerts'], $superAdminMiddleware); +$router->get('/api/super-admin/schools', [\App\Controllers\SuperAdminController::class, 'schools'], $superAdminMiddleware); +$router->post('/api/super-admin/schools/save', [\App\Controllers\SuperAdminController::class, 'saveSchool'], $superAdminMiddleware); +$router->post('/api/super-admin/schools/toggle', [\App\Controllers\SuperAdminController::class, 'toggleSchool'], $superAdminMiddleware); +$router->get('/api/super-admin/staff', [\App\Controllers\SuperAdminController::class, 'staff'], $superAdminMiddleware); +$router->post('/api/super-admin/staff/save', [\App\Controllers\SuperAdminController::class, 'saveStaff'], $superAdminMiddleware); +$router->post('/api/super-admin/staff/toggle', [\App\Controllers\SuperAdminController::class, 'toggleStaff'], $superAdminMiddleware); +$router->get('/api/super-admin/directorates', [\App\Controllers\SuperAdminController::class, 'directorates'], $superAdminMiddleware); +$router->post('/api/super-admin/directorates/save', [\App\Controllers\SuperAdminController::class, 'saveDirectorate'], $superAdminMiddleware); +$router->post('/api/super-admin/directorates/toggle', [\App\Controllers\SuperAdminController::class, 'toggleDirectorate'], $superAdminMiddleware); // OTP Authentication Routes (WhatsApp via Nabeh Gateway + Device Fingerprinting) $router->post('/api/auth/otp/request', [\App\Controllers\AuthController::class, 'requestOtp'], [\App\Middlewares\RateLimitMiddleware::class]); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 27406ac..afff250 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -25,6 +25,11 @@ - تشغيل الطالب يرفض الدرس الذي لا يملك فيديو حقيقياً؛ أزيل فيديو الاختبار العام. - تقديم الامتحان وتصحيحه ودفتر الأخطاء والتقارير الشهرية تتم من الخادم فقط. - المحادثة تستخدم `auth_identity_id` وتسمح فقط بعلاقة طالب/معلم لها تصريح دورة فعلي. +- مبدأ تخزين المحتوى التعليمي المعتمد: قاعدة البيانات تحفظ الفهرس والبيانات الوصفية والحالة والصلاحيات فقط، ولا تحفظ نصوص الكتب أو ملفات HTML أو الفيديو داخلها. +- ملفات الكتب بعد استخراجها ومراجعتها (Markdown)، وأوراق العمل، وملفات HTML للتجارب تُحفظ كـ versioned objects في تخزين الملفات الأصلي (R2 أو مساحة تخزين خاصة مكافئة)، وتُستدعى بروابط موقعة ومحددة الصلاحية. +- الفيديوهات تُرفع إلى R2 وتُحوّل إلى HLS؛ قاعدة البيانات تحفظ `object_key` و`hls_url` وحالة المعالجة والنسخة فقط. +- Redis ليس مخزناً دائماً للمحتوى: يستخدم للجلسات وOTP وrate limits، ولـ cache قصير العمر لشجرة المنهج أو Markdown المتكرر، مع TTL وإمكانية إعادة البناء من R2. +- كل درس/وحدة/ورقة عمل/تجربة لها سجل إصدار ومصدر ومراجع وحالة (`draft`, `review`, `published`, `archived`) لمنع عرض ملف غير معتمد. - اختبارات Smoke للتطبيقات الأربعة ناجحة، وتحليل Flutter لا يحتوي أخطاء compile. - فحص syntax لجميع ملفات PHP ناجح و`git diff --check` نظيف.