feat(ecosystem): full Grade 10 curriculum sync, real device fingerprinting, teacher video dashboard, and official textbook assets
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user