feat: Introduce new interactive content blocks including Fill-in-the-Blanks, Short Answer, Ordering, and Matching, with corresponding API, database, and UI integration.

This commit is contained in:
2025-12-19 17:03:26 -03:00
parent 0988213eb7
commit 57b8d7c0a1
17 changed files with 1513 additions and 32 deletions
@@ -0,0 +1,32 @@
-- Mirrored schema for courses, modules, and lessons in the LMS
-- This table stores the published version of the content
CREATE TABLE courses (
id UUID PRIMARY KEY, -- Using the same ID as CMS
title TEXT NOT NULL,
description TEXT,
instructor_id UUID NOT NULL,
start_date TIMESTAMPTZ,
end_date TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE modules (
id UUID PRIMARY KEY,
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
position INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lessons (
id UUID PRIMARY KEY,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content_type TEXT NOT NULL,
content_url TEXT,
transcription JSONB,
metadata JSONB,
position INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+135 -2
View File
@@ -1,9 +1,9 @@
use axum::{
extract::State,
extract::{State, Path},
http::StatusCode,
Json,
};
use common::models::{Course, Enrollment};
use common::models::{Course, Enrollment, Module, Lesson};
use sqlx::PgPool;
use uuid::Uuid;
@@ -37,3 +37,136 @@ pub async fn get_course_catalog(
Ok(Json(courses))
}
pub async fn ingest_course(
State(pool): State<PgPool>,
Json(payload): Json<common::models::PublishedCourse>,
) -> Result<StatusCode, StatusCode> {
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Upsert Course
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
instructor_id = EXCLUDED.instructor_id,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
updated_at = EXCLUDED.updated_at"
)
.bind(payload.course.id)
.bind(&payload.course.title)
.bind(&payload.course.description)
.bind(payload.course.instructor_id)
.bind(payload.course.start_date)
.bind(payload.course.end_date)
.bind(payload.course.updated_at)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to upsert course during ingestion: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 2. Clear existing modules and lessons for this course to ensure perfect sync
// Cascading delete on courses(id) handles lessons too
sqlx::query("DELETE FROM modules WHERE course_id = $1")
.bind(payload.course.id)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 3. Insert Modules and Lessons
for pub_module in payload.modules {
sqlx::query(
"INSERT INTO modules (id, course_id, title, position, created_at)
VALUES ($1, $2, $3, $4, $5)"
)
.bind(pub_module.module.id)
.bind(payload.course.id)
.bind(&pub_module.module.title)
.bind(pub_module.module.position)
.bind(pub_module.module.created_at)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for lesson in pub_module.lessons {
sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
.bind(lesson.id)
.bind(pub_module.module.id)
.bind(&lesson.title)
.bind(&lesson.content_type)
.bind(&lesson.content_url)
.bind(&lesson.transcription)
.bind(&lesson.metadata)
.bind(lesson.position)
.bind(lesson.created_at)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
}
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}
pub async fn get_course_outline(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut pub_modules = Vec::new();
// 3. Fetch Lessons
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
pub_modules.push(common::models::PublishedModule {
module,
lessons,
});
}
Ok(Json(common::models::PublishedCourse {
course,
modules: pub_modules,
}))
}
pub async fn get_lesson_content(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(lesson))
}
+9
View File
@@ -21,9 +21,18 @@ async fn main() {
.await
.expect("Failed to connect to database");
// Run migrations automatically
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let app = Router::new()
.route("/catalog", get(handlers::get_course_catalog))
.route("/enroll", post(handlers::enroll_user))
.route("/ingest", post(handlers::ingest_course))
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.with_state(pool);
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));