Implement verified curriculum, video review, watch sessions, ledger safeguards, and remove demo data

This commit is contained in:
Hamza-Ayed
2026-09-09 17:04:08 +03:00
parent 0dc5df7b5e
commit 5af684a892
49 changed files with 1809 additions and 3005 deletions
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/**
* Builds a review queue from manifest.json for migration 20260909.
* Default mode is dry-run and never connects to MySQL. --apply creates only
* unverified lessons, draft bundles, and draft assets; it never publishes.
*
* Usage:
* php backend/scripts/backfill_curriculum_identity.php
* php backend/scripts/backfill_curriculum_identity.php --apply
*/
$apply = in_array('--apply', $argv, true);
$manifestPath = dirname(__DIR__) . '/storage/curriculum/manifest.json';
$storageRoot = realpath(dirname(__DIR__) . '/storage/curriculum');
if (!$storageRoot || !is_file($manifestPath)) {
fwrite(STDERR, "Manifest or curriculum storage is unavailable.\n");
exit(1);
}
$manifest = json_decode((string)file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
fwrite(STDERR, "Manifest is not valid JSON.\n");
exit(1);
}
$rows = [];
foreach ($manifest as $gradeKey => $grade) {
foreach (($grade['subjects'] ?? []) as $subjectKey => $subject) {
foreach (($subject['semesters'] ?? []) as $semesterKey => $semester) {
foreach (($semester['units'] ?? []) as $unitKey => $unit) {
foreach (($unit['lessons'] ?? []) as $lesson) {
$file = (string)($lesson['file'] ?? '');
$candidate = realpath($storageRoot . '/' . ltrim($file, '/'));
$insideStorage = $candidate && str_starts_with(
$candidate,
rtrim($storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
);
$rows[] = [
'grade_key' => (string)$gradeKey,
'subject_key' => (string)$subjectKey,
'semester_key' => (string)$semesterKey,
'unit_key' => (string)$unitKey,
'lesson_key' => (string)($lesson['id'] ?? ''),
'title' => (string)($lesson['title'] ?? ''),
'manifest_path' => $file,
'path' => $insideStorage ? $candidate : null,
'missing' => !$insideStorage,
];
}
}
}
}
}
$identityCounts = [];
foreach ($rows as $row) {
$identity = implode('|', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]);
$identityCounts[$identity] = ($identityCounts[$identity] ?? 0) + 1;
}
$conflicts = array_keys(array_filter($identityCounts, static fn (int $count): bool => $count > 1));
$missing = array_values(array_filter($rows, static fn (array $row): bool => $row['missing']));
$report = [
'mode' => $apply ? 'apply' : 'dry_run',
'curriculum_version' => 'manifest-2026-09-09',
'lessons_found' => count($rows),
'missing_assets' => array_map(static fn (array $row): array => [
'identity' => implode('/', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]),
'title' => $row['title'],
'manifest_path' => $row['manifest_path'] ?: null,
'reason' => $row['manifest_path'] === '' ? 'missing_manifest_file_reference' : 'file_not_found_in_local_storage',
], $missing),
'identity_conflicts' => $conflicts,
'eligible_for_review_queue' => count($rows) - count($missing),
];
if (!$apply) {
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(($missing || $conflicts) ? 2 : 0);
}
if ($conflicts) {
fwrite(STDERR, "Conflicting curriculum identities found. Resolve them before --apply.\n");
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(2);
}
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($rows as $row) {
if ($row['missing']) {
continue;
}
$lessonUuid = selfUuid();
Database::query(
"INSERT INTO curriculum_lessons
(uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status)
VALUES (?, ?, ?, ?, ?, ?, 'manifest-2026-09-09', ?, ?, 'unverified')
ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)",
[$lessonUuid, $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $row['title'], $row['manifest_path']]
);
$lessonId = (int)Database::selectOne(
"SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = 'manifest-2026-09-09' LIMIT 1",
[$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]
)['id'];
Database::query(
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status)
VALUES (?, ?, 'manifest-2026-09-09', 'draft')
ON DUPLICATE KEY UPDATE id = id",
[selfUuid(), $lessonId]
);
$bundleId = (int)Database::selectOne(
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = 'manifest-2026-09-09' LIMIT 1",
[$lessonId]
)['id'];
$sha = hash_file('sha256', $row['path']);
$bytes = filesize($row['path']);
Database::query(
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, review_status)
VALUES (?, 'lesson_markdown', 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'draft')
ON DUPLICATE KEY UPDATE id = id",
[selfUuid(), $row['manifest_path'], $bytes, $sha, 'manifest.json']
);
$assetId = (int)Database::selectOne(
"SELECT id FROM content_assets WHERE storage_driver = 'local' AND storage_key = ? AND sha256 = ? LIMIT 1",
[$row['manifest_path'], $sha]
)['id'];
Database::query(
"INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, 'primary_lesson')",
[$bundleId, $assetId]
);
}
$pdo->commit();
$report['created_review_queue'] = true;
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, "Backfill rolled back: {$e->getMessage()}\n");
exit(1);
}
function selfUuid(): 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));
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* Explicit migration runner. It never drops a database and does not run from
* HTTP. Usage: php backend/scripts/run_migrations.php [--dry-run|--status].
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$mode = in_array('--status', $argv, true) ? 'status' : (in_array('--dry-run', $argv, true) ? 'dry_run' : 'apply');
$dir = dirname(__DIR__) . '/migrations';
$files = glob($dir . '/*.sql') ?: [];
sort($files, SORT_STRING);
$pdo = Database::getConnection();
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) NOT NULL PRIMARY KEY, sha256 CHAR(64) NOT NULL, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$applied = Database::select('SELECT filename, sha256, applied_at FROM schema_migrations ORDER BY filename');
$known = array_column($applied, null, 'filename');
foreach ($files as $file) {
$name = basename($file); $sha = hash_file('sha256', $file);
if (isset($known[$name])) {
if (!hash_equals((string)$known[$name]['sha256'], $sha)) { fwrite(STDERR, "REFUSE {$name}: applied migration checksum changed.\n"); exit(2); }
echo "APPLIED {$name} {$known[$name]['applied_at']}\n"; continue;
}
if ($mode === 'status') { echo "PENDING {$name}\n"; continue; }
if ($mode === 'dry_run') { echo "WOULD APPLY {$name}\n"; continue; }
$sql = file_get_contents($file);
if ($sql === false || trim($sql) === '') { fwrite(STDERR, "REFUSE {$name}: unreadable or empty.\n"); exit(2); }
echo "APPLYING {$name}\n";
try {
// MySQL DDL may commit implicitly. The migration is recorded only after
// its complete SQL succeeds; repair any partial DDL manually before retry.
$pdo->exec($sql);
Database::insert('INSERT INTO schema_migrations (filename, sha256) VALUES (?, ?)', [$name, $sha]);
echo "APPLIED {$name}\n";
} catch (Throwable $e) {
fwrite(STDERR, "FAILED {$name}: {$e->getMessage()}\n");
exit(1);
}
}
+10
View File
@@ -19,6 +19,16 @@ function updateVideoTask(string $file, array $changes): void {
if ($taskId === '' || !file_exists($stateFile)) exit(1);
$task = json_decode(file_get_contents($stateFile), true) ?: [];
// Legacy worker has no teacher_submission/video_version binding and therefore
// cannot prove which curriculum lesson or candidate it is publishing. Keep the
// uploaded file for an operator; do not create or overwrite a lesson by title.
updateVideoTask($stateFile, [
'status' => 'needs_submission_pipeline',
'progress' => 0,
'message' => 'هذا العامل موقوف حتى يمرر مسار الإرسال نسخة فيديو مرتبطة بالدرس المنهجي.',
]);
exit(2);
try {
updateVideoTask($stateFile, ['status' => 'processing', 'progress' => 15, 'message' => 'جاري تحويل الفيديو إلى HLS...']);
$file = (string)($task['file'] ?? '');