feat(ecosystem): full Grade 10 curriculum sync, real device fingerprinting, teacher video dashboard, and official textbook assets

This commit is contained in:
Hamza-Ayed
2026-09-11 16:46:40 +03:00
parent fc8f874bf9
commit d1bc39604b
404 changed files with 118547 additions and 1093 deletions
+64 -9
View File
@@ -191,7 +191,8 @@ class AuthController
return;
}
$fullName = trim((string)($body['full_name'] ?? ''));
$deviceFingerprint = trim((string)($body['device_fingerprint'] ?? 'browser_default'));
$deviceFingerprint = $this->resolveDeviceFingerprint($request, $body);
$platform = $this->resolveDevicePlatform($deviceFingerprint, $request);
$phoneHash = Security::blindIndex($cleanPhone);
@@ -328,10 +329,10 @@ class AuthController
// 3. Register / Update Device Fingerprint in user_devices
try {
Database::query(
"INSERT INTO user_devices (user_id, device_fingerprint, platform, is_active, last_active_at)
VALUES (?, ?, 'web', 1, NOW())
ON DUPLICATE KEY UPDATE last_active_at = NOW(), is_active = 1",
[$user['id'], $deviceFingerprint]
"INSERT INTO user_devices (user_id, identity_id, device_fingerprint, platform, is_active, last_active_at)
VALUES (?, ?, ?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE identity_id = VALUES(identity_id), platform = VALUES(platform), last_active_at = NOW(), is_active = 1",
[$user['id'], $identityId, $deviceFingerprint, $platform]
);
} catch (\Exception $e) {
error_log("Device recording notice: " . $e->getMessage());
@@ -675,10 +676,8 @@ class AuthController
$gradeLevel = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
$stream = trim((string)($body['stream'] ?? 'scientific'));
$nationalId = trim((string)($body['national_id'] ?? ''));
$deviceFingerprint = trim((string)($request->getHeader('x-device-fingerprint', '')));
if ($deviceFingerprint === '') {
$deviceFingerprint = 'browser_default';
}
$deviceFingerprint = $this->resolveDeviceFingerprint($request, $body);
$platform = $this->resolveDevicePlatform($deviceFingerprint, $request);
if (empty($fullName) || empty($nationalId)) {
$response->status(400)->json(['status' => 'error', 'message' => 'الاسم الكامل والرقم الوطني مطلوبان']);
@@ -718,6 +717,17 @@ class AuthController
Database::insert("INSERT IGNORE INTO guardian_students (guardian_id, student_id) VALUES (?, ?)", [$guardian['id'], $sId]);
}
try {
Database::query(
"INSERT INTO user_devices (user_id, identity_id, device_fingerprint, platform, is_active, last_active_at)
VALUES (?, ?, ?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE identity_id = VALUES(identity_id), platform = VALUES(platform), last_active_at = NOW(), is_active = 1",
[$sId, $identityId, $deviceFingerprint, $platform]
);
} catch (\Exception $e) {
error_log("Device recording notice on student registration: " . $e->getMessage());
}
$this->generateSessionAndRespond(
$sId, $sUuid, 'student', $deviceFingerprint, $phone, $fullName, $response, 'تم استكمال التسجيل بنجاح',
$identityId,
@@ -872,4 +882,49 @@ class AuthController
return $value;
}
}
private function resolveDeviceFingerprint(Request $request, array $body): string
{
$fingerprint = trim((string)($body['device_fingerprint'] ?? ''));
if ($fingerprint === '') {
$fingerprint = trim((string)$request->getHeader('x-device-fingerprint', ''));
}
// Disallow synthetic mock strings in real authentication
$obsoleteMocks = [
'saqel_student_flutter',
'saqel_teacher_flutter',
'saqel_admin_flutter',
'saqel_super_admin_flutter',
];
if (in_array($fingerprint, $obsoleteMocks, true)) {
error_log("[Security Audit] Obsolete synthetic device fingerprint rejected: {$fingerprint}");
$fingerprint = '';
}
if ($fingerprint === '') {
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown_client';
$fingerprint = 'saqel_web_' . substr(hash('sha256', $ip . '|' . $ua), 0, 32);
}
return $fingerprint;
}
private function resolveDevicePlatform(string $fingerprint, Request $request): string
{
if (str_starts_with($fingerprint, 'saqel_android_')) return 'android';
if (str_starts_with($fingerprint, 'saqel_ios_')) return 'ios';
if (str_starts_with($fingerprint, 'saqel_macos_')) return 'macos';
if (str_starts_with($fingerprint, 'saqel_windows_')) return 'windows';
if (str_starts_with($fingerprint, 'saqel_linux_')) return 'linux';
if (str_starts_with($fingerprint, 'saqel_web_')) return 'web';
$headerPlatform = strtolower(trim((string)$request->getHeader('x-device-platform', '')));
if (in_array($headerPlatform, ['android', 'ios', 'macos', 'windows', 'linux', 'web'], true)) {
return $headerPlatform;
}
return 'other';
}
}
@@ -262,7 +262,8 @@ class CurriculumController
// are rebuilt from approved, rights-cleared assets in a published
// bundle, and expose an opaque asset UUID rather than a storage path.
$resourceRows = Database::select(
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type,
"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,
pba.role, pba.sort_order
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
@@ -276,29 +277,68 @@ class CurriculumController
ORDER BY cl.subject_key, pba.role, pba.sort_order, a.id"
);
$resourcesBySubject = [];
$seenAssetsBySubject = [];
foreach ($resourceRows as $row) {
$group = $row['role'] === 'textbook' ? 'textbooks' : 'worksheets';
$subjectKey = (string)$row['subject_key'];
$assetId = (string)$row['asset_id'];
$seenAssetsBySubject[$subjectKey] ??= [];
if (isset($seenAssetsBySubject[$subjectKey][$assetId])) {
continue;
}
$seenAssetsBySubject[$subjectKey][$assetId] = true;
$group = $row['role'] === 'textbook' ? 'textbooks' : 'worksheets';
$resourcesBySubject[$subjectKey] ??= ['textbooks' => [], 'worksheets' => []];
$title = '';
if ($group === 'textbooks') {
$ref = strtolower((string)($row['source_reference'] ?? ''));
$subjectNames = [
'math' => 'الرياضيات',
'physics' => 'الفيزياء',
'chemistry' => 'الكيمياء',
'biology' => 'العلوم الحياتية (الأحياء)',
'earth' => 'علوم الأرض والبيئة',
'arabic' => 'اللغة العربية (مهارات الاتصال)',
'english' => 'اللغة الإنجليزية (Action Pack 10)',
'islamic' => 'التربية الإسلامية',
'history' => 'تاريخ الأردن',
'geography' => 'الجغرافيا',
'civics' => 'التربية الوطنية والمدنية',
'financial' => 'الثقافة المالية',
'digital' => 'المهارات الرقمية والتكنولوجيا',
];
$foundSub = 'المقرر';
foreach ($subjectNames as $k => $name) {
if (str_contains($ref, $k) || str_contains(strtolower($subjectKey), $k)) {
$foundSub = $name;
break;
}
}
$part = '';
if (str_contains($ref, 'part1') || str_contains($ref, 'part_1') || str_contains($ref, 'semester_1')) {
$part = ' (الفصل الأول)';
} elseif (str_contains($ref, 'part2') || str_contains($ref, 'part_2') || str_contains($ref, 'semester_2')) {
$part = ' (الفصل الثاني)';
}
$title = "الكتاب المدرسي الرسمي: $foundSub$part";
} else {
$lTitle = trim((string)($row['lesson_title'] ?? ''));
$title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية";
}
$resourcesBySubject[$subjectKey][$group][] = [
'asset_id' => (string)$row['asset_id'],
'asset_id' => $assetId,
'asset_type' => (string)$row['asset_type'],
'mime_type' => (string)$row['mime_type'],
'type' => $group === 'textbooks' ? 'textbook' : 'worksheet',
'title' => $title,
];
}
foreach ($tree as $gKey => $grade) {
if (!isset($tree[$gKey]['subjects']) || !is_array($tree[$gKey]['subjects'])) continue;
foreach ($tree[$gKey]['subjects'] as $sKey => $subject) {
$subjectResources = $resourcesBySubject[(string)$sKey] ?? ['textbooks' => [], 'worksheets' => []];
foreach (['textbooks', 'worksheets'] as $group) {
foreach ($subjectResources[$group] as $index => &$resource) {
$resource['title'] = $group === 'textbooks'
? 'كتاب منشور ' . ($index + 1)
: 'ورقة عمل منشورة ' . ($index + 1);
}
unset($resource);
}
$tree[$gKey]['subjects'][$sKey]['resources'] = [
'textbooks' => ['items' => $subjectResources['textbooks']],
'worksheets' => ['items' => $subjectResources['worksheets']],
@@ -432,7 +432,7 @@ class TeacherController
'status' => 'success',
'data' => [
'financial_status' => 'unavailable',
'financial_message' => 'الأرباح والسحب متوقفان حتى اكتمال دفتر الاستحقاق وربط مزود الدفع.',
'financial_message' => 'قيد التفعيل المؤسسي — بانتظار اعتماد سياسة كليك والربط البنكي الرسمي ومطابقة الدقائق التعليمية المستحقة.',
'audience_breakdown' => [
'institutional_students' => ['count' => 0],
'marketplace_students' => ['count' => 0],
@@ -76,6 +76,72 @@ class VideoController
} catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); }
}
/** GET /api/teacher/submissions */
public function listTeacherSubmissions(Request $request, Response $response): void
{
$teacherId = (int)$request->user_id;
try {
$rows = Database::select(
"SELECT ts.id AS submission_id, ts.uuid AS submission_uuid, ts.status AS submission_status,
ts.current_published_video_version_id, ts.created_at, ts.updated_at,
cl.id AS lesson_id, cl.uuid AS curriculum_lesson_uuid, cl.title AS lesson_title,
cl.subject_key, cl.grade_level, cl.semester_key, cl.unit_key, cl.lesson_key,
vv.id AS video_version_id, vv.uuid AS video_version_uuid, vv.version_number,
vv.status AS video_status, vv.published_at, vv.created_at AS version_created_at
FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id
WHERE ts.teacher_id = ?
ORDER BY ts.updated_at DESC, vv.version_number DESC",
[$teacherId]
);
$submissions = [];
foreach ($rows as $row) {
$subId = (string)$row['submission_uuid'];
if (!isset($submissions[$subId])) {
$submissions[$subId] = [
'submission_uuid' => $row['submission_uuid'],
'submission_status' => $row['submission_status'],
'lesson_title' => $row['lesson_title'],
'subject_key' => $row['subject_key'],
'grade_level' => $row['grade_level'],
'semester_key' => $row['semester_key'],
'unit_key' => $row['unit_key'],
'lesson_key' => $row['lesson_key'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
'versions' => [],
];
}
if (!empty($row['video_version_uuid'])) {
$submissions[$subId]['versions'][] = [
'video_version_uuid' => $row['video_version_uuid'],
'version_number' => (int)$row['version_number'],
'video_status' => $row['video_status'],
'is_current_published' => ($row['current_published_video_version_id'] == $row['video_version_id']),
'published_at' => $row['published_at'],
'created_at' => $row['version_created_at'],
];
}
}
$response->json([
'status' => 'success',
'data' => [
'submissions' => array_values($submissions),
'total' => count($submissions),
]
]);
} catch (\Throwable $e) {
error_log('listTeacherSubmissions failed: ' . $e->getMessage());
$response->status(500)->json([
'status' => 'error',
'message' => 'تعذر جلب قائمة الحصص المرفوعة للمعلم.'
]);
}
}
/** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */
public function listPublishedLessonVideos(Request $request, Response $response): void
{