Files
saqel/backend/scripts/publish_grade10_bundle.php
T

254 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* Publication manager for Grade 10 curriculum bundles and assets.
*
* Publishes bundles only after independent lesson and asset approval.
*
* Safety rules:
* - Default mode is a read-only dry run. It writes nothing unless `--apply` is present.
* - All database writes happen inside a single atomic transaction.
* - This script never grants academic approval or clears rights.
*
* Usage:
* php backend/scripts/publish_grade10_bundle.php --status
* php backend/scripts/publish_grade10_bundle.php --pilot
* php backend/scripts/publish_grade10_bundle.php --pilot --apply
* php backend/scripts/publish_grade10_bundle.php --subject=math_10 --unit=unit_01 --apply
* php backend/scripts/publish_grade10_bundle.php --all --apply
* php backend/scripts/publish_grade10_bundle.php --pilot --revert --apply
*/
$projectRoot = dirname(__DIR__, 2);
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$statusOnly = in_array('--status', $argv, true);
$isPilot = in_array('--pilot', $argv, true);
$isAll = in_array('--all', $argv, true);
$isRevert = in_array('--revert', $argv, true);
$subjectFilter = null;
$unitFilter = null;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--subject=')) {
$subjectFilter = substr($arg, strlen('--subject='));
}
if (str_starts_with($arg, '--unit=')) {
$unitFilter = substr($arg, strlen('--unit='));
}
}
// 1. Status mode
if ($statusOnly) {
echo "=== Saqel Grade 10 Publication Status ===\n";
$lessonStats = Database::select(
"SELECT source_status, COUNT(*) as count FROM curriculum_lessons WHERE grade_key = 'grade_10' GROUP BY source_status"
);
echo "\nCurriculum Lessons (source_status):\n";
foreach ($lessonStats as $s) {
echo sprintf(" - %-15s: %d\n", $s['source_status'], $s['count']);
}
$bundleStats = Database::select(
"SELECT pb.status, COUNT(*) as count
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE cl.grade_key = 'grade_10'
GROUP BY pb.status"
);
echo "\nPublication Bundles (status):\n";
foreach ($bundleStats as $s) {
echo sprintf(" - %-15s: %d\n", $s['status'], $s['count']);
}
$assetStats = Database::select(
"SELECT a.review_status, a.rights_status, COUNT(*) as count
FROM content_assets a
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE cl.grade_key = 'grade_10'
GROUP BY a.review_status, a.rights_status"
);
echo "\nContent Assets (review_status / rights_status):\n";
foreach ($assetStats as $s) {
echo sprintf(" - %-12s / %-15s: %d\n", $s['review_status'], $s['rights_status'], $s['count']);
}
exit(0);
}
// 2. Resolve target lessons
$where = ["grade_key = 'grade_10'"];
$params = [];
if ($isPilot) {
// 12 key pilot lessons representing Math, Physics, Arabic, and Islamic
$pilotPaths = [
// Math 10: Semester 1, Unit 1 (Equations)
'grade_10/math_10/semester_1/unit_01/lesson_01.md',
'grade_10/math_10/semester_1/unit_01/lesson_02.md',
'grade_10/math_10/semester_1/unit_01/lesson_03.md',
'grade_10/physics_10/semester_1/unit_02/lesson_03.md',
// Physics 10: Semester 1, Units 1 & 2
'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
'grade_10/physics_10/semester_1/unit_01/lesson_02.md',
'grade_10/physics_10/semester_1/unit_02/lesson_01.md',
'grade_10/physics_10/semester_1/unit_02/lesson_02.md',
// Arabic 10: Semester 1, Unit 1
'grade_10/arabic_10/semester_1/unit_01/lesson_01.md',
'grade_10/arabic_10/semester_1/unit_01/lesson_02.md',
// Islamic 10: Semester 1, Unit 1
'grade_10/islamic_10/semester_1/unit_01/lesson_01.md',
'grade_10/islamic_10/semester_1/unit_01/lesson_02.md',
];
$placeholders = implode(',', array_fill(0, count($pilotPaths), '?'));
$where[] = "source_manifest_path IN ($placeholders)";
$params = array_merge($params, $pilotPaths);
} elseif ($isAll) {
// All Grade 10 lessons
} else {
if ($subjectFilter !== null) {
$where[] = "subject_key = ?";
$params[] = $subjectFilter;
}
if ($unitFilter !== null) {
$where[] = "unit_key = ?";
$params[] = $unitFilter;
}
if ($subjectFilter === null && $unitFilter === null) {
fwrite(STDERR, "Error: Specify --pilot, --all, or filter with --subject=... [--unit=...]\n");
fwrite(STDERR, "Run with --help or --status to view available options.\n");
exit(1);
}
}
$sqlWhere = implode(' AND ', $where);
$targetLessons = Database::select(
"SELECT id, uuid, subject_key, semester_key, unit_key, lesson_key, title, source_manifest_path, source_status
FROM curriculum_lessons
WHERE {$sqlWhere}
ORDER BY subject_key, semester_key, unit_key, lesson_key",
$params
);
if (empty($targetLessons)) {
echo "No matching curriculum lessons found for the specified criteria.\n";
exit(0);
}
if ($isPilot && count($targetLessons) !== 12) {
fwrite(STDERR, "REFUSE: pilot must contain exactly 12 matching lessons; found " . count($targetLessons) . ".\n");
exit(2);
}
$lessonIds = array_column($targetLessons, 'id');
$idList = implode(',', array_map('intval', $lessonIds));
// 3. Plan summary
$targetAction = $isRevert ? 'WITHDRAW PUBLISHED BUNDLES' : 'PUBLISH REVIEWED BUNDLES';
echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN');
echo sprintf("Action: %s\n", $targetAction);
echo sprintf("Matched Lessons: %d\n", count($targetLessons));
echo "\nTarget Lessons:\n";
foreach ($targetLessons as $idx => $l) {
echo sprintf(" %2d. [%-12s] %-10s / %-10s : %s (Current: %s)\n",
$idx + 1,
$l['subject_key'],
$l['unit_key'],
$l['lesson_key'],
$l['title'],
$l['source_status']
);
}
function publicationBlockers(array $targetLessons, bool $lock): array
{
$blockers = [];
foreach ($targetLessons as $lesson) {
$id = (int)$lesson['id'];
$path = $lesson['source_manifest_path'];
$detail = Database::selectOne(
'SELECT source_status, reviewed_by, reviewed_at FROM curriculum_lessons WHERE id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (!$detail || $detail['source_status'] !== 'approved' || empty($detail['reviewed_by']) || empty($detail['reviewed_at'])) {
$blockers[] = "{$path}: academic approval with reviewer and date is missing";
}
$bundles = Database::select(
'SELECT id, status FROM publication_bundles WHERE curriculum_lesson_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (count($bundles) !== 1) {
$blockers[] = "{$path}: expected exactly one bundle; found " . count($bundles);
continue;
}
$bundle = $bundles[0];
if (!in_array($bundle['status'], ['review', 'published'], true)) {
$blockers[] = "{$path}: bundle must be in review before publication";
}
$assets = Database::select(
'SELECT a.id, a.asset_type, a.review_status, a.rights_status, a.source_reference, pba.role
FROM publication_bundle_assets pba JOIN content_assets a ON a.id = pba.content_asset_id
WHERE pba.publication_bundle_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[(int)$bundle['id']]
);
if ($assets === []) {
$blockers[] = "{$path}: bundle has no assets";
}
$hasPrimary = false;
foreach ($assets as $asset) {
if ($asset['role'] === 'primary_lesson' && $asset['asset_type'] === 'lesson_markdown') $hasPrimary = true;
if ($asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || trim((string)$asset['source_reference']) === '') {
$blockers[] = "{$path}: asset {$asset['id']} lacks review, rights clearance, or source";
}
}
if (!$hasPrimary) $blockers[] = "{$path}: approved lesson Markdown is missing";
}
return $blockers;
}
if (!$apply && !$isRevert) {
$blockers = publicationBlockers($targetLessons, false);
foreach ($blockers as $blocker) echo "BLOCKED {$blocker}\n";
echo $blockers === [] ? "\n[Ready for reviewed publication; append --apply.]\n" : "\n[Publication blocked; resolve review evidence first.]\n";
exit($blockers === [] ? 0 : 2);
}
if (!$apply) {
echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n";
exit(0);
}
// 4. Apply changes inside a transaction
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
if ($isRevert) {
// Withdrawal preserves academic decisions, rights records and history.
Database::query(
"UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'"
);
} else {
$blockers = publicationBlockers($targetLessons, true);
if ($blockers !== []) {
throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers));
}
Database::query(
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'"
);
}
$pdo->commit();
echo "\n=== SUCCESS: " . ($isRevert ? "Reverted" : "Published") . " " . count($targetLessons) . " lessons successfully. ===\n";
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "\nFAILED: Publication transaction rolled back: " . $e->getMessage() . "\n");
exit(1);
}