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:
@@ -17,3 +17,4 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dotenvy.workspace = true
|
||||
tower-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -3,12 +3,73 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Module, Lesson};
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub async fn publish_course(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, 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 each Module
|
||||
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(PublishedModule {
|
||||
module,
|
||||
lessons,
|
||||
});
|
||||
}
|
||||
|
||||
let payload = PublishedCourse {
|
||||
course,
|
||||
modules: pub_modules,
|
||||
};
|
||||
|
||||
// 4. Send to LMS
|
||||
// Using service name for Docker compatibility
|
||||
let client = reqwest::Client::new();
|
||||
let res = client.post("http://lms-service:3002/ingest")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to reach LMS service: {}", e);
|
||||
StatusCode::BAD_GATEWAY
|
||||
})?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
tracing::error!("LMS ingestion failed with status: {}", res.status());
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ModuleQuery {
|
||||
pub course_id: Option<Uuid>,
|
||||
|
||||
@@ -22,6 +22,12 @@ 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 cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
@@ -30,6 +36,7 @@ async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
|
||||
.route("/courses/{id}", get(handlers::get_course))
|
||||
.route("/courses/{id}/publish", post(handlers::publish_course))
|
||||
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user