273 lines
12 KiB
PHP
273 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
use App\Core\Database;
|
|
|
|
class GuardianController
|
|
{
|
|
public function requestChildLink(Request $request, Response $response): void
|
|
{
|
|
$body = $request->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));
|
|
}
|
|
}
|