Update Saqel Platform: 2026-09-10 12:26:22

This commit is contained in:
Hamza-Ayed
2026-09-10 12:32:01 +03:00
parent 09dfd665b5
commit 960d4c8604
28 changed files with 690 additions and 2864 deletions
@@ -183,6 +183,29 @@ class CurriculumController
public function getTree(Request $request, Response $response): void
{
$tree = CurriculumService::getCurriculumTree();
// Manifest resource paths are intake metadata, not student-facing grants.
// Until they are represented by approved assets in a published bundle, do
// not expose them as available textbooks or worksheets.
$removeUnpublishedResources = function (&$node) use (&$removeUnpublishedResources): void {
if (!is_array($node)) {
return;
}
if (isset($node['resources']) && is_array($node['resources'])) {
foreach ($node['resources'] as &$resourceGroup) {
if (is_array($resourceGroup) && array_key_exists('items', $resourceGroup)) {
$resourceGroup['items'] = [];
}
}
unset($resourceGroup);
}
foreach ($node as &$child) {
$removeUnpublishedResources($child);
}
unset($child);
};
$removeUnpublishedResources($tree);
try {
$published = Database::select("SELECT cl.uuid, cl.source_manifest_path, COUNT(vv.id) AS video_count FROM curriculum_lessons cl LEFT JOIN teacher_submissions ts ON ts.curriculum_lesson_id=cl.id AND ts.status='published' LEFT JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' WHERE cl.source_status='approved' GROUP BY cl.id, cl.uuid, cl.source_manifest_path");
$byPath=[];
@@ -192,6 +215,52 @@ class CurriculumController
if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]);
}
unset($grade,$subject,$semester,$unit,$lesson);
// The manifest describes intake files only. Student-visible resources
// 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,
pba.role, pba.sort_order
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id
JOIN content_assets a ON a.id = pba.content_asset_id
WHERE pb.status = 'published'
AND cl.source_status = 'approved'
AND a.review_status = 'approved'
AND a.rights_status = 'cleared'
AND pba.role IN ('textbook', 'worksheet')
ORDER BY cl.subject_key, pba.role, pba.sort_order, a.id"
);
$resourcesBySubject = [];
foreach ($resourceRows as $row) {
$group = $row['role'] === 'textbook' ? 'textbooks' : 'worksheets';
$subjectKey = (string)$row['subject_key'];
$resourcesBySubject[$subjectKey] ??= ['textbooks' => [], 'worksheets' => []];
$resourcesBySubject[$subjectKey][$group][] = [
'asset_id' => (string)$row['asset_id'],
'asset_type' => (string)$row['asset_type'],
'mime_type' => (string)$row['mime_type'],
'type' => $group === 'textbooks' ? 'textbook' : 'worksheet',
];
}
foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as $subjectKey => &$subject) {
$subjectResources = $resourcesBySubject[(string)$subjectKey] ?? ['textbooks' => [], 'worksheets' => []];
foreach (['textbooks', 'worksheets'] as $group) {
foreach ($subjectResources[$group] as $index => &$resource) {
$resource['title'] = $group === 'textbooks'
? 'كتاب منشور ' . ($index + 1)
: 'ورقة عمل منشورة ' . ($index + 1);
}
unset($resource);
}
$subject['resources'] = [
'textbooks' => ['items' => $subjectResources['textbooks']],
'worksheets' => ['items' => $subjectResources['worksheets']],
];
}
unset($grade, $subject);
} catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); }
$response->json(['status'=>'success','data'=>$tree]);
}
@@ -61,35 +61,25 @@ class SuperAdminController
}
public function overview(Request $request, Response $response): void
{
CliqPaymentService::ensureSchema();
$counts = [
'total_directorates' => $this->count('directorates'),
'total_schools' => $this->count('schools'),
'total_students' => $this->count('students'),
'total_teachers' => $this->count('teachers'),
];
$payments = Database::selectOne(
"SELECT COALESCE(SUM(CASE WHEN verification_status = 'verified' THEN amount_jod ELSE 0 END), 0) AS inflow FROM cliq_payments"
);
$payouts = Database::selectOne(
"SELECT SUM(CASE WHEN status IN ('queued', 'processing') THEN 1 ELSE 0 END) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_jod ELSE 0 END), 0) AS paid_out
FROM payout_queue"
);
$inflow = (float)($payments['inflow'] ?? 0);
$paidOut = (float)($payouts['paid_out'] ?? 0);
$response->json([
'status' => 'success',
'data' => array_merge($counts, [
'gross_margin_percent' => $inflow > 0 ? round((($inflow - $paidOut) / $inflow) * 100, 2) : 0.0,
'treasury_balance_jod' => max(0, $inflow - $paidOut),
'total_cliq_inflow_jod' => $inflow,
'pending_payouts_count' => (int)($payouts['pending_count'] ?? 0),
'r2_cost_savings_jod' => 0.0,
'uptime_percent' => 0.0,
// Payment settlement and infrastructure monitoring are not
// connected yet. Null is intentional: zero would be a claim.
'gross_margin_percent' => null,
'treasury_balance_jod' => null,
'total_cliq_inflow_jod' => null,
'pending_payouts_count' => null,
'r2_cost_savings_jod' => null,
'uptime_percent' => null,
'measurement_notes' => [
'financial_metrics' => 'غير متاحة حتى ربط مزود الدفع ودفتر التسوية.',
'r2_cost_savings_jod' => 'يتطلب ربط Cloudflare Billing Analytics',
'uptime_percent' => 'يتطلب ربط مزود مراقبة خارجي',
],
@@ -124,7 +114,7 @@ class SuperAdminController
public function securityAlerts(Request $request, Response $response): void
{
// No synthetic alerts: an audit-event pipeline will populate this endpoint.
$response->json(['status' => 'success', 'data' => []]);
$response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']);
}
private function count(string $table): int
+9 -1
View File
@@ -327,6 +327,9 @@ class VideoController
);
$auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight);
if (($preflight['decision'] ?? '') !== 'approved') {
// The candidate contains no accepted media yet, so make it reusable.
// Do not leave a failed local quality gate blocking a future upload.
TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId);
$response->status(422)->json([
'status' => 'needs_review',
'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.',
@@ -335,6 +338,7 @@ class VideoController
return;
}
$reviewQueued = false;
try {
// Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy)
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false);
@@ -364,11 +368,12 @@ class VideoController
$lessonId,
hash_file('sha256', (string)$_FILES['video']['tmp_name'])
);
$reviewQueued = true;
// Immediately send HTTP 201 response to Flutter client and close connection
$responsePayload = [
'status' => 'success',
'message' => 'تم رفع الفيديو واعتماده مبدئياً بنجاح. تجري المزامنة السحابية والتحليل السقراطي في الخلفية.',
'message' => 'تم رفع الفيديو إلى طابور المراجعة. لن يظهر للطلاب قبل اكتمال الأدلة وقرار المراجع البشري.',
'data' => array_merge($uploadResult, [
'lesson_id' => $lessonId,
'title' => $title,
@@ -406,6 +411,9 @@ class VideoController
exit;
} catch (\Throwable $e) {
if (!$reviewQueued) {
TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId);
}
$response->status(500)->json([
'status' => 'error',
'message' => $e->getMessage()
@@ -154,74 +154,6 @@ class CurriculumService
return [];
}
// Dynamically enrich manifest nodes with live uploaded videos from MySQL
try {
$dbLessons = \App\Core\Database::select(
"SELECT id, title, curriculum_key, hls_url, duration_seconds
FROM lessons
WHERE hls_url IS NOT NULL AND hls_url != ''"
);
if (!empty($dbLessons)) {
$attachVideos = function (&$node) use (&$attachVideos, $dbLessons) {
if (!is_array($node)) return;
if (isset($node['lessons']) && is_array($node['lessons'])) {
foreach ($node['lessons'] as &$lesson) {
if (!is_array($lesson)) continue;
$lessonId = (string)($lesson['id'] ?? '');
$lessonFile = (string)($lesson['file'] ?? '');
$lessonFileNoExt = preg_replace('/\.md$/i', '', $lessonFile);
$lessonTitle = (string)($lesson['title'] ?? '');
foreach ($dbLessons as $dbl) {
$currKey = (string)($dbl['curriculum_key'] ?? '');
$currKeyNoExt = preg_replace('/\.md$/i', '', $currKey);
$dbTitle = (string)($dbl['title'] ?? '');
$matched = false;
if ($currKeyNoExt !== '' && $lessonFileNoExt !== '') {
if ($currKeyNoExt === $lessonFileNoExt) {
$matched = true;
} elseif (str_contains($currKeyNoExt, '/') && (
str_ends_with($lessonFileNoExt, '/' . ltrim($currKeyNoExt, '/')) ||
str_ends_with($currKeyNoExt, '/' . ltrim($lessonFileNoExt, '/'))
)) {
$matched = true;
}
}
if (!$matched && $dbTitle !== '' && $lessonTitle !== '') {
$normDb = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($dbTitle));
$normLes = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($lessonTitle));
if ($normDb === $normLes && mb_strlen($normDb) > 8) {
$matched = true;
}
}
if ($matched) {
$lesson['has_video'] = true;
$lesson['video_url'] = $dbl['hls_url'];
if (!empty($dbl['duration_seconds'])) {
$lesson['duration_seconds'] = (int)$dbl['duration_seconds'];
}
break;
}
}
}
}
foreach ($node as &$child) {
if (is_array($child)) {
$attachVideos($child);
}
}
};
$attachVideos($tree);
}
} catch (\Throwable $e) {
error_log("Enrich curriculum tree notice: " . $e->getMessage());
}
return $tree;
}
@@ -28,6 +28,7 @@ final class PublishedContentService
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE a.uuid = ?
AND a.review_status = 'approved'
AND a.rights_status = 'cleared'
AND pb.status = 'published'
AND cl.source_status = 'approved'
ORDER BY pb.published_at DESC, pb.id DESC
@@ -41,6 +41,17 @@ final class TeacherSubmissionService {
[$versionUuid, $teacherId]
) === 1;
}
/** Releases a reservation only when no media was accepted for review. */
public static function releaseUploadReservation(int $teacherId, string $versionUuid): void {
if (!self::uuidValid($versionUuid)) return;
Database::execute(
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
SET vv.status='draft',
ts.status=CASE WHEN ts.current_published_video_version_id IS NULL THEN 'draft' ELSE 'published' END
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='uploading'",
[$versionUuid, $teacherId]
);
}
private static function commit(\PDO $pdo,int $teacher,string $op,string $key,string $hash,array $r):array {Database::insert('INSERT INTO submission_idempotency_keys (teacher_id,operation,idempotency_key,request_sha256,response_json,http_status) VALUES (?,?,?,?,?,?)',[$teacher,$op,$key,$hash,json_encode($r,JSON_UNESCAPED_UNICODE),$r['http_status']]);$pdo->commit();return $r;}
private static function result(int $status,string $code,string $message):array{return ['http_status'=>$status,'status'=>$code,'message'=>$message];}
private static function uuidValid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
+2 -2
View File
@@ -76,7 +76,7 @@ $router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumControl
$router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']);
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent']);
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware);
@@ -91,7 +91,7 @@ $router->get('/api/curriculum/search', function ($request, $response) {
});
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
/**
* Registers supplied Grade 10 textbook PDFs as review-only source assets.
*
* Default mode is read-only. --apply copies PDFs into curriculum storage and
* creates draft, rights-review-required content_assets records. It never
* approves rights, publishes a bundle, or invents curriculum lessons.
*
* Usage:
* php backend/scripts/import_grade10_book_sources.php
* php backend/scripts/import_grade10_book_sources.php --apply
*/
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$projectRoot = dirname(__DIR__, 2);
$sourceRoot = $projectRoot . '/books';
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
$manifestPath = $curriculumRoot . '/manifest.json';
if (!is_dir($sourceRoot) || !is_file($manifestPath)) {
fwrite(STDERR, "Books directory or curriculum manifest is unavailable.\n");
exit(1);
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
fwrite(STDERR, "Curriculum manifest is not valid JSON.\n");
exit(1);
}
$knownSemesters = [];
foreach (($manifest['grade_10']['subjects'] ?? []) as $subjectKey => $subject) {
foreach (array_keys($subject['semesters'] ?? []) as $semesterKey) {
$knownSemesters[$subjectKey][$semesterKey] = true;
}
}
$subjectPatterns = [
'arabic_10' => ['اللغة العربية', 'العربية لغتي'],
'english_10' => ['اللغة الإنجليزية'],
'math_10' => ['الرياضيات'],
'physics_10' => ['الفيزياء'],
'chemistry_10' => ['الكيمياء'],
'biology_10' => ['العلوم الحياتية'],
'earth_science_10' => ['علوم الأرض والبيئة'],
'digital_skills_10' => ['المهارات الرقمية'],
'islamic_10' => ['التربية الإسلامية'],
'history_10' => ['التاريخ'],
'geography_10' => ['الجغرافيا'],
'civic_10' => ['التربية الوطنية والمدنية'],
'financial_literacy_10' => ['الثقافة المالية'],
];
$rows = [];
foreach (glob($sourceRoot . '/*.pdf') ?: [] as $sourcePath) {
$filename = basename($sourcePath);
$subjectKey = null;
foreach ($subjectPatterns as $candidate => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains($filename, $pattern)) {
$subjectKey = $candidate;
break 2;
}
}
}
$semesterKey = str_contains($filename, 'الفصل الأول') ? 'semester_1'
: (str_contains($filename, 'الفصل الثاني') ? 'semester_2' : null);
$isInstitutionalBook = str_starts_with($filename, 'كتاب الطالب') || str_starts_with($filename, 'كتاب التمارين');
$sha256 = hash_file('sha256', $sourcePath);
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', pathinfo($filename, PATHINFO_FILENAME)) ?: 'book';
// Arabic filenames normalize to "book" with the ASCII-only fallback;
// append integrity bytes so no two supplied books can overwrite each other.
$storageKey = sprintf('sources/grade_10/%s/%s/%s-%s.pdf', $subjectKey ?: 'unclassified', $semesterKey ?: 'unclassified', trim($safeName, '-'), substr($sha256, 0, 12));
$rows[] = [
'filename' => $filename,
'source_path' => $sourcePath,
'subject_key' => $subjectKey,
'semester_key' => $semesterKey,
'asset_type' => $isInstitutionalBook ? 'textbook_pdf' : 'other',
'pages' => pdfPages($sourcePath),
'byte_size' => filesize($sourcePath),
'sha256' => $sha256,
'storage_key' => $storageKey,
'manifest_scope_exists' => $subjectKey !== null && $semesterKey !== null && isset($knownSemesters[$subjectKey][$semesterKey]),
'intake_status' => !$isInstitutionalBook ? 'manual_rights_and_academic_review_required'
: ($subjectKey === null || $semesterKey === null ? 'unclassified_metadata' : 'ready_for_source_review'),
];
}
usort($rows, static fn(array $a, array $b): int => strcmp($a['filename'], $b['filename']));
$report = [
'mode' => $apply ? 'apply' : 'dry_run',
'policy' => 'Draft source registration only; no publication or rights clearance.',
'books_found' => count($rows),
'manifest_supported_sources' => count(array_filter($rows, static fn(array $row): bool => $row['manifest_scope_exists'])),
'sources_outside_current_manifest' => count(array_filter($rows, static fn(array $row): bool => !$row['manifest_scope_exists'])),
'rows' => $rows,
];
if (!$apply) {
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
}
require_once dirname(__DIR__) . '/app/bootstrap.php';
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($rows as $row) {
if ($row['asset_type'] !== 'textbook_pdf' || $row['subject_key'] === null || $row['semester_key'] === null) {
continue;
}
$destination = $curriculumRoot . '/' . $row['storage_key'];
if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) {
throw new RuntimeException('Unable to create textbook storage directory.');
}
if (!is_file($destination)) {
if (!copy($row['source_path'], $destination)) {
throw new RuntimeException('Unable to copy textbook source: ' . $row['filename']);
}
}
if (!hash_equals($row['sha256'], hash_file('sha256', $destination))) {
throw new RuntimeException('Copied textbook checksum mismatch: ' . $row['filename']);
}
Database::query(
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status)
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, ?, 'review_required', 'draft')
ON DUPLICATE KEY UPDATE byte_size=VALUES(byte_size), source_reference=VALUES(source_reference)",
[uuid(), $row['storage_key'], $row['byte_size'], $row['sha256'], 'books/' . $row['filename']]
);
}
$pdo->commit();
$report['registered_draft_sources'] = true;
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "Source import rolled back: {$e->getMessage()}\n");
exit(1);
}
function pdfPages(string $path): ?int
{
$output = [];
$status = 0;
exec('pdfinfo ' . escapeshellarg($path) . ' 2>/dev/null', $output, $status);
if ($status !== 0) {
return null;
}
foreach ($output as $line) {
if (preg_match('/^Pages:\s+(\d+)$/', $line, $match)) {
return (int) $match[1];
}
}
return null;
}
function uuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}