Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
+68 -29
View File
@@ -5,12 +5,12 @@ declare(strict_types=1);
/**
* Publication manager for Grade 10 curriculum bundles and assets.
*
* Transitions lessons and their assets from `draft`/`unverified` to `published`/`approved`.
* 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.
* - Only assets belonging to the selected curriculum lessons are updated.
* - This script never grants academic approval or clears rights.
*
* Usage:
* php backend/scripts/publish_grade10_bundle.php --status
@@ -93,7 +93,7 @@ if ($isPilot) {
'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/math_10/semester_1/unit_01/intro_and_project.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',
@@ -140,12 +140,16 @@ 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 ? 'REVERT TO DRAFT' : 'PUBLISH / APPROVE';
$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));
@@ -161,8 +165,60 @@ foreach ($targetLessons as $idx => $l) {
);
}
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 execute, append --apply to your command.]\n";
echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n";
exit(0);
}
@@ -172,34 +228,17 @@ $pdo->beginTransaction();
try {
if ($isRevert) {
// Revert to draft
// Withdrawal preserves academic decisions, rights records and history.
Database::query(
"UPDATE curriculum_lessons SET source_status = 'unverified' WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'draft', published_at = NULL WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE 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
SET a.review_status = 'draft', a.rights_status = 'review_required'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'"
);
} else {
// Publish and approve
$blockers = publicationBlockers($targetLessons, true);
if ($blockers !== []) {
throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers));
}
Database::query(
"UPDATE curriculum_lessons SET source_status = 'approved', reviewed_at = CURRENT_TIMESTAMP WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE 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
SET a.review_status = 'approved', a.rights_status = 'cleared'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'"
);
}