Update Saqel Platform: 2026-09-10 12:26:22
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);}
|
||||
|
||||
Reference in New Issue
Block a user