254 lines
9.9 KiB
PHP
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);
|
|
}
|