diff --git a/docs/academy-content-delivery.md b/docs/academy-content-delivery.md new file mode 100644 index 0000000..bf0b63a --- /dev/null +++ b/docs/academy-content-delivery.md @@ -0,0 +1,9 @@ +# Governed Academy Content Delivery + +`GET /api/academy/content` is the learner-facing read-only catalogue. It is cacheable and returns only courses marked `published` whose ordered lesson path is complete and consists entirely of published lesson revisions. It never returns drafts, review requests, reviewer notes, or author identities. + +The React Academy renders bundled versioned content immediately, then replaces it only when this endpoint returns a complete compatible catalogue. This keeps local development, offline recovery, and a temporary content-store outage safe without making static source the primary path after publication. + +Courses and lessons follow the same author, reviewer, publisher separation. An author saves and submits a draft, a reviewer completes content, research, and accessibility checks, and a publisher releases the immutable record. A course cannot be publicly visible until every lesson it orders is published. + +The final Foundations migration must be performed through those identities in the Admin Academy. Automation must not silently mark educational content reviewed or published. diff --git a/docs/roadmap.md b/docs/roadmap.md index 813e423..41c5681 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,9 +8,9 @@ | Item | Current state | | --- | --- | -| Current milestone | Milestone 5H - Revision comparison and Foundations migration, in progress | -| Current version | `v0.3.25` | -| Current working branch | `feat/academy-revision-comparison` | +| Current milestone | Milestone 5H - Governed content delivery and Foundations migration, in progress | +| Current version | `v0.3.26` | +| Current working branch | `feat/academy-content-delivery` | | Active pull request(s) | Revision-comparison PR in preparation. PR #14 is integrated into `main`. | | Base branch | `main` is canonical. | | Next planned milestone | Full Foundations migration through Admin Academy, then Milestone 5 review | @@ -49,6 +49,7 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi - `v0.3.23` - Structured course authoring and ordering forms - `v0.3.24` - Academy review and publishing workflow - `v0.3.25` - Academy revision comparison +- `v0.3.26` - Governed Academy content delivery Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate. @@ -399,6 +400,7 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda **Remaining** - [ ] Use the complete draft/review/publish flow to migrate and maintain the full Foundations course. - [ ] Conduct the Milestone 5 practical authoring review against that real content. +- [ ] Complete the live author, reviewer, and publisher approvals for Foundations; this is intentionally not bypassed by a deployment script. ### Milestone 6 - Educational Media Pipeline @@ -502,3 +504,4 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - **2026-07-17:** Milestone 5F replaces the remaining course JSON route with structured metadata and accessible ordering controls. The editor uses the existing course validation contract and starts from the real Foundations path. - **2026-07-17:** Milestone 5G adds a guarded author-submit, reviewer-check, publisher-release workflow. Server-side transitions reject skipped review and preserve published-revision immutability. - **2026-07-17:** Milestone 5H adds a readable revision-comparison view rather than relying on authors to infer changes from version numbers. The remaining proof is real Foundations migration through the completed workflow. +- **2026-07-17:** The public Academy now prefers a cacheable, read-only published-content API. It excludes drafts, incomplete course paths, and unpublished lessons, and immediately falls back to bundled content when no complete published catalogue is available. diff --git a/package-lock.json b/package-lock.json index f7b9470..d7c3419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "femmevoice", - "version": "0.3.25", + "version": "0.3.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "femmevoice", - "version": "0.3.25", + "version": "0.3.26", "dependencies": { "@vitejs/plugin-react": "^5.0.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index b60238f..423aa65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "femmevoice", - "version": "0.3.25", + "version": "0.3.26", "private": true, "type": "module", "scripts": { diff --git a/server/academy_content.py b/server/academy_content.py index 57b1d15..1e64592 100644 --- a/server/academy_content.py +++ b/server/academy_content.py @@ -53,6 +53,18 @@ def review_result_status(decision): return "in_review" if decision == "approved" else "draft" +def build_public_catalogue(course_records, lesson_records): + lessons_by_slug = {item["lesson"]["slug"]: item["lesson"] for item in lesson_records if item.get("lesson", {}).get("slug")} + courses = [] + for record in course_records: + course = record.get("course") or {} + ordered_lessons = [lessons_by_slug.get(slug) for slug in course.get("lessonIds", [])] + if not ordered_lessons or any(item is None for item in ordered_lessons): + continue + courses.append({"course": course, "lessons": ordered_lessons, "publishedAt": record.get("published_at")}) + return {"schemaVersion": 1, "courses": courses} + + def validate_course_document(course): if not isinstance(course, dict): raise ValueError("Course must be an object.") diff --git a/server/app.py b/server/app.py index 64db6b8..2ebef25 100644 --- a/server/app.py +++ b/server/app.py @@ -20,7 +20,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.security import check_password_hash, generate_password_hash from academy_history import normalize_academy_history -from academy_content import can_submit_for_review, review_result_status, validate_course_document, validate_lesson_document, validate_review +from academy_content import build_public_catalogue, can_submit_for_review, review_result_status, validate_course_document, validate_lesson_document, validate_review from reminder_logic import VALID_REMINDER_TONES, normalize_reminder_days ROOT = Path(__file__).resolve().parent.parent @@ -112,7 +112,7 @@ def apply_security_headers(response): response.headers["Cross-Origin-Resource-Policy"] = "same-origin" if request.is_secure: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" - if request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or request.path.startswith("/api/academy/") or request.path.startswith("/api/account/academy-history"): + if request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or (request.path.startswith("/api/academy/") and request.path != "/api/academy/content") or request.path.startswith("/api/account/academy-history"): response.headers["Cache-Control"] = "no-store" return response @@ -541,6 +541,16 @@ def delete_personal_data(): return response +@app.get("/api/academy/content") +def public_academy_content(): + """Expose only complete, published course revisions to anonymous learners.""" + lessons = list(academy_lessons_collection.find({"status": "published"}, {"_id": 0, "lesson": 1, "updated_at": 1})) + courses = list(academy_courses_collection.find({"status": "published"}, {"_id": 0, "course": 1, "updated_at": 1}).sort("updated_at", -1).limit(100)) + response = jsonify(build_public_catalogue(courses, lessons)) + response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300" + return response + + @app.get("/api/admin/academy/lessons") def list_academy_lessons_for_admin(): user = user_from_request() @@ -575,11 +585,54 @@ def save_academy_course_draft(course_id): return auth_error(str(error)) if course["id"] != course_id: return auth_error("Course path must match the course id.") + existing = academy_courses_collection.find_one({"course_id": course_id}) + if existing and existing.get("status") == "published": + return auth_error("Published courses are immutable. Create a new course revision before changing it.", 409) timestamp = now_iso() academy_courses_collection.update_one({"course_id": course_id}, {"$set": {"course_id": course_id, "course": course, "status": "draft", "updated_at": timestamp, "authored_by": user["username"]}, "$setOnInsert": {"created_at": timestamp}}, upsert=True) return jsonify({"ok": True, "status": "draft", "updated_at": timestamp}) +@app.put("/api/admin/academy/courses//submit-review") +def submit_academy_course_for_review(course_id): + if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("author") + record = academy_courses_collection.find_one({"course_id": course_id}) + if not user: return auth_error("Academy author access is required.", 403) + if not record: return auth_error("Save the course draft before requesting review.", 404) + if not can_submit_for_review(record.get("status")): return auth_error("Only a draft course can be submitted for review.", 409) + academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "review_requested", "review_requested_at": now_iso(), "review_requested_by": user["username"], "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": "review_requested"}) + + +@app.put("/api/admin/academy/courses//review") +def review_academy_course(course_id): + if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("reviewer") + record = academy_courses_collection.find_one({"course_id": course_id}) + if not user: return auth_error("Academy reviewer access is required.", 403) + if not record: return auth_error("Academy course was not found.", 404) + if record.get("status") != "review_requested": return auth_error("An author must submit this course for review first.", 409) + try: review = validate_review((request.get_json(silent=True) or {}).get("review")) + except ValueError as error: return auth_error(str(error)) + review.update({"reviewed_by": user["username"], "reviewed_at": now_iso()}) + status = review_result_status(review["decision"]) + academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": status, "review": review, "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": status, "review": review}) + + +@app.put("/api/admin/academy/courses//publish") +def publish_academy_course(course_id): + if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("publisher") + record = academy_courses_collection.find_one({"course_id": course_id}) + if not user: return auth_error("Academy publisher access is required.", 403) + if not record: return auth_error("Academy course was not found.", 404) + if record.get("status") != "in_review" or record.get("review", {}).get("decision") != "approved": return auth_error("An approved course review is required before publishing.", 409) + academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "published", "published_at": now_iso(), "published_by": user["username"], "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": "published"}) + + @app.get("/api/admin/academy/lessons//") def get_academy_lesson_for_admin(lesson_id, version): user = user_from_request() diff --git a/server/test_academy_content.py b/server/test_academy_content.py index 04f86fb..d2eed12 100644 --- a/server/test_academy_content.py +++ b/server/test_academy_content.py @@ -1,6 +1,6 @@ import unittest -from academy_content import can_submit_for_review, review_result_status, validate_course_document, validate_lesson_document, validate_review +from academy_content import build_public_catalogue, can_submit_for_review, review_result_status, validate_course_document, validate_lesson_document, validate_review def lesson(): @@ -32,3 +32,10 @@ def test_course_keeps_an_ordered_unique_lesson_path(self): course["lessonIds"].append("welcome") with self.assertRaises(ValueError): validate_course_document(course) + + def test_public_catalogue_excludes_courses_with_unpublished_lesson_gaps(self): + course = {"course": {"slug": "foundations", "lessonIds": ["welcome", "safety"]}, "published_at": "2026-07-17T00:00:00Z"} + lessons = [{"lesson": {"slug": "welcome", "title": "Welcome"}}] + self.assertEqual(build_public_catalogue([course], lessons)["courses"], []) + lessons.append({"lesson": {"slug": "safety", "title": "Safety"}}) + self.assertEqual(build_public_catalogue([course], lessons)["courses"][0]["lessons"][1]["title"], "Safety") diff --git a/src/academy/AcademyView.jsx b/src/academy/AcademyView.jsx index 483cf17..3c2b075 100644 --- a/src/academy/AcademyView.jsx +++ b/src/academy/AcademyView.jsx @@ -1,12 +1,24 @@ +import { useEffect, useState } from "react"; import { ArrowLeft, ArrowRight, BookOpen, CheckCircle2, Clock3, ShieldCheck } from "lucide-react"; -import { ACADEMY_COURSES, formatCourseDuration, getAcademyCourse } from "./catalog"; +import { formatCourseDuration } from "./catalog"; +import { loadPublicAcademyContent } from "../api"; import LessonPlayer from "./LessonPlayer"; import { getFoundationsLesson } from "./content/foundations"; import AcademyHistory from "./AcademyHistory"; +import { normalizePublishedAcademyContent, staticAcademyContent } from "./publicContent"; export default function AcademyView({ courseSlug, lessonSlug, onOpenCourse, onBack, history, historySyncEnabled, onLessonHistory, onDeleteHistory, onAddJournal }) { - const course = courseSlug ? getAcademyCourse(courseSlug) : null; - const lesson = courseSlug === "foundations" && lessonSlug ? getFoundationsLesson(lessonSlug) : null; + const [content, setContent] = useState(staticAcademyContent); + const course = courseSlug ? content.courses.find((item) => item.slug === courseSlug) ?? null : null; + const lesson = lessonSlug ? course?.lessonDocuments?.[lessonSlug] ?? course?.lessons.find((item) => item.slug === lessonSlug)?.document ?? (courseSlug === "foundations" ? getFoundationsLesson(lessonSlug) : null) : null; + + useEffect(() => { + let active = true; + loadPublicAcademyContent().then((payload) => { + if (active) setContent(normalizePublishedAcademyContent(payload)); + }).catch(() => {}); + return () => { active = false; }; + }, []); if (course && lesson) { return onOpenCourse(course.slug)} />; @@ -47,7 +59,7 @@ export default function AcademyView({ courseSlug, lessonSlug, onOpenCourse, onBa
- {ACADEMY_COURSES.map((item) => ( + {content.courses.map((item) => (

{item.eyebrow}

{item.status === "available" ? "Ready" : "Planned"}

{item.title}

diff --git a/src/academy/AdminAcademy.jsx b/src/academy/AdminAcademy.jsx index ef0c24d..d4f1093 100644 --- a/src/academy/AdminAcademy.jsx +++ b/src/academy/AdminAcademy.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { BookOpen, ChevronDown, ChevronUp, Eye, FilePlus2, Save, Send, ShieldCheck, Stamp, Trash2 } from "lucide-react"; -import { listAcademyAdminCourses, listAcademyAdminLessons, loadAcademyAdminLesson, publishAcademyAdminLesson, reviewAcademyAdminLesson, saveAcademyAdminCourse, saveAcademyAdminLesson, submitAcademyAdminLessonForReview } from "../api"; +import { listAcademyAdminCourses, listAcademyAdminLessons, loadAcademyAdminLesson, publishAcademyAdminCourse, publishAcademyAdminLesson, reviewAcademyAdminCourse, reviewAcademyAdminLesson, saveAcademyAdminCourse, saveAcademyAdminLesson, submitAcademyAdminCourseForReview, submitAcademyAdminLessonForReview } from "../api"; import { ACADEMY_COURSES } from "./catalog"; import { FOUNDATIONS_LESSONS } from "./content/foundations"; import LessonPlayer from "./LessonPlayer"; @@ -14,6 +14,8 @@ export default function AdminAcademy({ roles }) { const [courses, setCourses] = useState([]); const [lesson, setLesson] = useState(null); const [course, setCourse] = useState(null); + const [courseStatus, setCourseStatus] = useState("draft"); + const [courseReview, setCourseReview] = useState({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); const [comparison, setComparison] = useState(null); const [lessonStatus, setLessonStatus] = useState("draft"); const [review, setReview] = useState({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); @@ -125,10 +127,12 @@ export default function AdminAcademy({ roles }) { tags: ["foundations", "transfeminine"], prerequisiteCourseIds: [], }, "Foundations course loaded with its current lesson order."); + setCourseStatus("draft"); setCourseReview({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); } function openCourse(record) { setCourseDraft(structuredClone(record.course), `Editing ${record.course.title}.`); + setCourseStatus(record.status ?? "draft"); setCourseReview(record.review ?? { decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); } function updateCourse(mutator) { @@ -143,11 +147,20 @@ export default function AdminAcademy({ roles }) { try { setStatus("Saving course draft..."); await saveAcademyAdminCourse(course.id, course); + setCourseStatus("draft"); setStatus("Course draft saved with its lesson order."); refresh(); } catch (error) { setStatus(error.message); } } + async function courseTransition(action) { + if (!course) return; + try { + const result = action === "submit" ? await submitAcademyAdminCourseForReview(course.id) : action === "review" ? await reviewAcademyAdminCourse(course.id, courseReview) : await publishAcademyAdminCourse(course.id); + setCourseStatus(result.status); if (result.review) setCourseReview(result.review); setStatus(action === "publish" ? "Course published. Learners can receive it only with its complete published lesson path." : "Course workflow updated."); refresh(); + } catch (error) { setStatus(error.message); } + } + function updateLesson(mutator) { if (!lesson) return; const next = structuredClone(lesson); @@ -191,7 +204,7 @@ export default function AdminAcademy({ roles }) { {status &&

{status}

} - +
; } @@ -233,16 +246,18 @@ function RevisionDiff({ current, previous, changeNote }) { return

Compared with v{previous.version}

{changeNote &&

Saved change note: {changeNote}

}{changes.length ?
{changes.map((change) =>
{change.label}
Before: {displayValue(change.before)}Now: {displayValue(change.after)}
)}
:

No summary fields changed. Review individual block content before publishing.

}
; } -function CourseEditor({ course, records, courses, onStart, onOpen, onChange, onSave, canAuthor }) { +function CourseEditor({ course, records, courses, onStart, onOpen, onChange, onSave, canAuthor, status, review, onReviewChange, roles, onTransition }) { const availableLessons = uniqueLessons(records); const savedCourseRecords = courses.filter((record) => record.course); const update = (field, value) => onChange((next) => { next[field] = value; }); const moveLesson = (index, direction) => onChange((next) => { const target = index + direction; if (target < 0 || target >= next.lessonIds.length) return; [next.lessonIds[index], next.lessonIds[target]] = [next.lessonIds[target], next.lessonIds[index]]; }); const removeLesson = (index) => onChange((next) => { next.lessonIds.splice(index, 1); }); const addLesson = (lessonId) => onChange((next) => { if (lessonId && !next.lessonIds.includes(lessonId)) next.lessonIds.push(lessonId); }); - return

Course structure

Keep the learning path legible.

Courses have an intentional order. Learners can pause, but authors should make the next useful step obvious.

{savedCourseRecords.length > 0 &&
Saved drafts{savedCourseRecords.map((record) => )}
}
{!course ?

Load the Foundations course to edit its metadata and order with structured controls.

:
update("title", value)} required /> update("slug", value)} required /> update("id", value)} required /> update("locale", value)} required /> update("estimatedMinutes", numberOrZero(value))} required /> update("tags", commaList(value))} hint="Comma separated." /> update("summary", value)} multiline required /> update("prerequisiteCourseIds", commaList(value))} hint="Future-ready; not enforced for learners yet." />

Lesson order

Build the route learners will follow.

{course.lessonIds.length === 0 ?

Add at least one lesson to define this course.

:
    {course.lessonIds.map((lessonId, index) => { const item = availableLessons.find((candidate) => candidate.id === lessonId); return
  1. {index + 1}. {item?.title ?? lessonId}{lessonId}
  2. ; })}
}
}
; + return

Course structure

Keep the learning path legible.

Courses have an intentional order. Learners can pause, but authors should make the next useful step obvious.

{savedCourseRecords.length > 0 &&
Saved drafts{savedCourseRecords.map((record) => )}
}
{!course ?

Load the Foundations course to edit its metadata and order with structured controls.

:
update("title", value)} required /> update("slug", value)} required /> update("id", value)} required /> update("locale", value)} required /> update("estimatedMinutes", numberOrZero(value))} required /> update("tags", commaList(value))} hint="Comma separated." /> update("summary", value)} multiline required /> update("prerequisiteCourseIds", commaList(value))} hint="Future-ready; not enforced for learners yet." />

Lesson order

Build the route learners will follow.

{course.lessonIds.length === 0 ?

Add at least one lesson to define this course.

:
    {course.lessonIds.map((lessonId, index) => { const item = availableLessons.find((candidate) => candidate.id === lessonId); return
  1. {index + 1}. {item?.title ?? lessonId}{lessonId}
  2. ; })}
}
}
; } +function CourseWorkflow({ status, review, onReviewChange, roles, onTransition }) { const ready = review.content_checked && review.research_checked && review.accessibility_checked; return
Course status: {formatContentStatus(status)}{status !== "published" && roles.includes("author") && }{roles.includes("reviewer") && <>{status === "review_requested" && }}{roles.includes("publisher") && status === "in_review" && review.decision === "approved" && }
; } + function EvidenceEditor({ lesson, onChange }) { const updateEvidence = (index, field, value) => onChange((next) => { next.evidence[index][field] = value; }); const removeEvidence = (index) => onChange((next) => { const removed = next.evidence[index]?.id; next.evidence.splice(index, 1); next.blocks.forEach((block) => { block.evidenceRefs = block.evidenceRefs.filter((id) => id !== removed); }); }); diff --git a/src/academy/publicContent.js b/src/academy/publicContent.js new file mode 100644 index 0000000..3ce389a --- /dev/null +++ b/src/academy/publicContent.js @@ -0,0 +1,27 @@ +import { ACADEMY_COURSES } from "./catalog.js"; +import { FOUNDATIONS_LESSONS } from "./content/foundations.js"; + +export function staticAcademyContent() { + return { source: "bundled", courses: ACADEMY_COURSES.map((course) => ({ ...course, lessons: course.slug === "foundations" ? FOUNDATIONS_LESSONS.map(toLessonSummary) : course.lessons })) }; +} + +export function normalizePublishedAcademyContent(payload, fallback = staticAcademyContent()) { + if (!payload || payload.schemaVersion !== 1 || !Array.isArray(payload.courses) || !payload.courses.length) return fallback; + const courses = payload.courses.map(({ course, lessons }) => { + if (!course?.slug || !Array.isArray(lessons) || !lessons.length) return null; + const bundled = fallback.courses.find((item) => item.slug === course.slug) ?? {}; + return { + ...bundled, + ...course, + status: "available", + lessonCount: lessons.length, + lessons: lessons.map(toLessonSummary), + lessonDocuments: Object.fromEntries(lessons.map((lesson) => [lesson.slug, lesson])), + }; + }).filter(Boolean); + return courses.length ? { source: "published", courses } : fallback; +} + +function toLessonSummary(lesson) { + return { slug: lesson.slug, title: lesson.title, objective: lesson.objective, durationMinutes: lesson.metadata?.estimatedMinutes ?? lesson.blocks?.reduce((total, block) => total + (block.durationMinutes || 0), 0) ?? 0, status: "available", document: lesson }; +} diff --git a/src/api.js b/src/api.js index 092baa9..9ae3502 100644 --- a/src/api.js +++ b/src/api.js @@ -26,6 +26,12 @@ async function secureRequest(url, options = {}) { return response.json(); } +export async function loadPublicAcademyContent() { + const response = await fetch("/api/academy/content", { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error("Published Academy content is unavailable."); + return response.json(); +} + export async function loadCloudProgress(deviceId) { const response = await fetch(`/api/progress/${encodeURIComponent(deviceId)}`, { headers: { Accept: "application/json" }, @@ -191,6 +197,10 @@ export async function saveAcademyAdminCourse(id, course) { return secureRequest(`/api/admin/academy/courses/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ course }) }); } +export async function submitAcademyAdminCourseForReview(id) { return secureRequest(`/api/admin/academy/courses/${encodeURIComponent(id)}/submit-review`, { method: "PUT" }); } +export async function reviewAcademyAdminCourse(id, review) { return secureRequest(`/api/admin/academy/courses/${encodeURIComponent(id)}/review`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ review }) }); } +export async function publishAcademyAdminCourse(id) { return secureRequest(`/api/admin/academy/courses/${encodeURIComponent(id)}/publish`, { method: "PUT" }); } + export async function listPrivateRecordings() { const response = await fetch("/api/recordings", { headers: { Accept: "application/json" } }); if (!response.ok) throw new Error("Could not load private recordings."); diff --git a/src/version.js b/src/version.js index ef96b86..c1fcaa0 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const APP_VERSION = "0.3.25"; +export const APP_VERSION = "0.3.26"; diff --git a/test/lesson-engine.test.mjs b/test/lesson-engine.test.mjs index 7eae521..555a6b5 100644 --- a/test/lesson-engine.test.mjs +++ b/test/lesson-engine.test.mjs @@ -9,6 +9,17 @@ import { FOUNDATIONS_LESSONS, getFoundationsLesson } from "../src/academy/conten import { advanceLessonProgress, canCompleteBlock, createLessonProgress, createLessonResumeStore, moveLessonProgress } from "../src/academy/lessonProgress.js"; import { addAcademyJournalEntry, clearAcademyHistory, createAcademyHistory, loadAcademyHistory, mergeAcademyHistories, recordLessonActivity, saveAcademyHistory, summarizeAcademyHistory } from "../src/academy/learnerHistory.js"; import { LESSON_SCHEMA_VERSION, lessonDuration, validateLesson } from "../src/academy/schema.js"; +import { normalizePublishedAcademyContent, staticAcademyContent } from "../src/academy/publicContent.js"; + +test("published Academy content replaces bundled content only when a complete catalogue is available", () => { + const fallback = staticAcademyContent(); + assert.equal(normalizePublishedAcademyContent({ schemaVersion: 1, courses: [] }, fallback), fallback); + const lesson = structuredClone(FOUNDATIONS_LESSONS[0]); + const result = normalizePublishedAcademyContent({ schemaVersion: 1, courses: [{ course: { id: "foundations", slug: "foundations", title: "Published Foundations", summary: "Published", locale: "en", estimatedMinutes: 8, lessonIds: [lesson.slug] }, lessons: [lesson] }] }, fallback); + assert.equal(result.source, "published"); + assert.equal(result.courses[0].title, "Published Foundations"); + assert.equal(result.courses[0].lessonDocuments[lesson.slug].version, 1); +}); test("Admin Academy presents structured lesson and course authoring controls before JSON escape hatches", async () => { const vite = await createServer({ server: { middlewareMode: true }, appType: "custom" });