getBody(); $nationalId = trim((string)($body['national_id'] ?? '')); $relationship = (string)($body['relationship_type'] ?? 'guardian'); if (!preg_match('/^[0-9]{10}$/', $nationalId)) { $response->status(422)->json(['status' => 'error', 'message' => 'الرقم الوطني يجب أن يتكون من 10 أرقام']); return; } if (!in_array($relationship, ['father', 'mother', 'brother', 'guardian'], true)) { $relationship = 'guardian'; } $student = Database::selectOne("SELECT id FROM students WHERE national_id_hash = ? LIMIT 1", [\App\Core\Security::blindIndex($nationalId)]); if (!$student) { // Do not reveal whether a national identity exists. $response->status(202)->json(['status' => 'pending', 'message' => 'تم استلام طلب الربط للمراجعة']); return; } $existing = Database::selectOne("SELECT id FROM guardian_students WHERE guardian_id = ? AND student_id = ? LIMIT 1", [$request->user_id, $student['id']]); if ($existing) { $response->json(['status' => 'success', 'message' => 'الطالب مرتبط بهذا الحساب مسبقاً']); return; } $uuid = $this->uuid(); Database::query( "INSERT INTO guardian_link_requests (uuid, guardian_id, student_id, relationship_type, status) VALUES (?, ?, ?, ?, 'pending') ON DUPLICATE KEY UPDATE relationship_type = VALUES(relationship_type)", [$uuid, $request->user_id, $student['id'], $relationship] ); $response->status(202)->json(['status' => 'pending', 'message' => 'أُرسل طلب الربط إلى الطالب للموافقة']); } public function pendingLinkRequests(Request $request, Response $response): void { $requests = Database::select( "SELECT glr.uuid, glr.relationship_type, glr.created_at, g.full_name AS guardian_name FROM guardian_link_requests glr JOIN guardians g ON g.id = glr.guardian_id WHERE glr.student_id = ? AND glr.status = 'pending' ORDER BY glr.created_at DESC", [$request->user_id] ); $response->json(['status' => 'success', 'data' => $requests]); } public function reviewLinkRequest(Request $request, Response $response): void { $uuid = trim((string)($request->getBody()['request_uuid'] ?? '')); $decision = (string)($request->getBody()['decision'] ?? ''); if (!in_array($decision, ['approved', 'rejected'], true)) { $response->status(422)->json(['status' => 'error', 'message' => 'قرار الربط غير صالح']); return; } $link = Database::selectOne( "SELECT * FROM guardian_link_requests WHERE uuid = ? AND student_id = ? AND status = 'pending' LIMIT 1", [$uuid, $request->user_id] ); if (!$link) { $response->status(404)->json(['status' => 'error', 'message' => 'طلب الربط غير موجود']); return; } Database::query("UPDATE guardian_link_requests SET status = ?, reviewed_at = NOW() WHERE id = ?", [$decision, $link['id']]); if ($decision === 'approved') { Database::query( "INSERT INTO guardian_students (guardian_id, student_id, relationship_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE relationship_type = VALUES(relationship_type), can_view_analytics = 1", [$link['guardian_id'], $request->user_id, $link['relationship_type']] ); } $response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ربط ولي الأمر' : 'تم رفض طلب الربط']); } /** * Get Dashboard Data for Guardian * GET /api/guardian/dashboard */ public function getDashboard(Request $request, Response $response): void { $guardianId = $request->user_id; // Fetch children linked to this guardian $children = Database::select( "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream FROM students s JOIN guardian_students gs ON gs.student_id = s.id WHERE gs.guardian_id = ?", [$guardianId] ); $dashboardData = []; foreach ($children as $child) { $studentId = $child['id']; // 1. Mastery Analytics (Readiness) $mastery = Database::selectOne( "SELECT tawjihi_readiness_score, exams_passed_count, exams_total_count FROM student_mastery_analytics WHERE student_id = ? ORDER BY updated_at DESC LIMIT 1", [$studentId] ); // 2. Socratic checkpoints count (in_video_checkpoint passed) $checkpointsCount = Database::selectOne( "SELECT COUNT(*) as cnt FROM exam_attempts ea JOIN exams e ON e.id = ea.exam_id WHERE ea.student_id = ? AND e.scope = 'in_video_checkpoint' AND ea.status = 'passed'", [$studentId] )['cnt'] ?? 0; // 3. Remediation count (weaknesses fixed) $remediationCount = Database::selectOne( "SELECT COUNT(DISTINCT ea.exam_id) as cnt FROM exam_attempts ea WHERE ea.student_id = ? AND ea.status = 'needs_remediation'", [$studentId] )['cnt'] ?? 0; // 4. Real Error Notebook metrics from student_error_notebook $errorStats = Database::selectOne( "SELECT COUNT(*) as total_errors, COALESCE(SUM(CASE WHEN status = 'mastered' THEN 1 ELSE 0 END), 0) as mastered_count, COALESCE(SUM(CASE WHEN status != 'mastered' THEN 1 ELSE 0 END), 0) as pending_count FROM student_error_notebook WHERE student_id = ?", [$studentId] ); $totalErrors = (int)($errorStats['total_errors'] ?? 0); $masteredErrors = (int)($errorStats['mastered_count'] ?? 0); $pendingErrors = (int)($errorStats['pending_count'] ?? 0); $errorMasteryPercentage = $totalErrors > 0 ? round(($masteredErrors / $totalErrors) * 100, 1) : 100.0; // 5. Forensic Weakness Log (Recent exam attempts with AI diagnostic) $diagnosticLogs = Database::select( "SELECT ea.percentage, ea.status, ea.weak_topics_json, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope FROM exam_attempts ea JOIN exams e ON e.id = ea.exam_id WHERE ea.student_id = ? ORDER BY ea.id DESC LIMIT 5", [$studentId] ); // Process weak topics JSON foreach ($diagnosticLogs as &$log) { if (!empty($log['weak_topics_json'])) { $log['weak_topics'] = json_decode($log['weak_topics_json'], true) ?: []; } else { $log['weak_topics'] = []; } unset($log['weak_topics_json']); } $dashboardData[] = [ 'student' => [ 'id' => $child['id'], 'uuid' => $child['uuid'], 'name' => $child['full_name'], 'national_id' => $this->maskNationalId((string)$child['national_id']), 'grade_stream' => ($child['grade_level'] ?? 'غير محدد') . ' (' . ($child['stream'] ?? 'عام') . ')' ], 'metrics' => [ 'readiness_score' => $mastery ? (float)$mastery['tawjihi_readiness_score'] : 0.0, 'checkpoints_passed' => (int)$checkpointsCount, 'remediations_flagged' => (int)$remediationCount, 'exams_passed_count' => $mastery ? (int)$mastery['exams_passed_count'] : 0, 'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0, 'error_notebook' => [ 'total_errors' => $totalErrors, 'mastered_count' => $masteredErrors, 'pending_count' => $pendingErrors, 'mastery_percentage' => $errorMasteryPercentage, ], 'errors_total' => $totalErrors, 'errors_mastered' => $masteredErrors, 'errors_pending' => $pendingErrors, 'errors_mastery_rate' => $errorMasteryPercentage, ], 'diagnostics' => $diagnosticLogs ]; } $response->json([ 'status' => 'success', 'data' => [ 'children' => $dashboardData ] ]); } /** * Get Child's Error Notebook for Guardian inspection * GET /api/guardian/children/{id}/error-notebook */ public function getChildErrorNotebook(Request $request, Response $response): void { $guardianId = (int)$request->user_id; $studentId = (int)$request->getParam('id'); // Verify guardian link and analytical authorization $linked = Database::selectOne( "SELECT id FROM guardian_students WHERE guardian_id = ? AND student_id = ? AND can_view_analytics = 1 LIMIT 1", [$guardianId, $studentId] ); if (!$linked) { $response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح بالوصول إلى دفتر أخطاء هذا الطالب']); return; } $items = Database::select( "SELECT * FROM student_error_notebook WHERE student_id = ? ORDER BY created_at DESC", [$studentId] ); $mastered = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'mastered')); $total = count($items); $bySubject = []; foreach ($items as $item) { $name = (string)($item['subject_name'] ?? ''); if ($name !== '') $bySubject[$name] = ($bySubject[$name] ?? 0) + 1; } $response->json([ 'status' => 'success', 'data' => [ 'summary' => [ 'total_errors' => $total, 'mastered_count' => $mastered, 'pending_count' => $total - $mastered, 'mastery_percentage' => $total > 0 ? round(($mastered / $total) * 100, 1) : 100.0, 'by_subject' => $bySubject, ], 'items' => $items, ], ]); } private function maskNationalId(string $storedValue): string { if ($storedValue === '') { return ''; } try { $decrypted = \App\Core\Security::decrypt($storedValue); $nationalId = $decrypted !== '' ? $decrypted : $storedValue; } catch (\Throwable $e) { $nationalId = $storedValue; } return strlen($nationalId) >= 4 ? str_repeat('*', max(0, strlen($nationalId) - 4)) . substr($nationalId, -4) : '****'; } private function uuid(): string { $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } }