$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); }