feat: update CMS service handlers and main application logic.
This commit is contained in:
@@ -18,3 +18,5 @@ tracing-subscriber.workspace = true
|
||||
dotenvy.workspace = true
|
||||
tower-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
bcrypt.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM rustlang/rust:nightly as builder
|
||||
FROM rustlang/rust:nightly AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Create users table for Instructors
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add instructor_id foreign key constraint to courses if not already applied
|
||||
-- (In the initial schema it was just a UUID, let's make it formal if possible)
|
||||
-- ALTER TABLE courses ADD CONSTRAINT fk_instructor FOREIGN KEY (instructor_id) REFERENCES users(id);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Add grading categories table
|
||||
CREATE TABLE grading_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
weight INTEGER NOT NULL CHECK (weight >= 0 AND weight <= 100),
|
||||
drop_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Update lessons with grading fields
|
||||
ALTER TABLE lessons ADD COLUMN grading_category_id UUID REFERENCES grading_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE lessons ADD COLUMN is_graded BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -3,11 +3,13 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule};
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse};
|
||||
use common::auth::create_jwt;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
pub async fn publish_course(
|
||||
State(pool): State<PgPool>,
|
||||
@@ -29,7 +31,16 @@ pub async fn publish_course(
|
||||
|
||||
let mut pub_modules = Vec::new();
|
||||
|
||||
// 3. Fetch Lessons for each Module
|
||||
// 3. Fetch Grading Categories
|
||||
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"SELECT * FROM grading_categories WHERE course_id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 4. 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)
|
||||
@@ -45,6 +56,7 @@ pub async fn publish_course(
|
||||
|
||||
let payload = PublishedCourse {
|
||||
course,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
};
|
||||
|
||||
@@ -80,6 +92,14 @@ pub struct LessonQuery {
|
||||
pub module_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GradingPayload {
|
||||
pub course_id: Uuid,
|
||||
pub name: String,
|
||||
pub weight: i32,
|
||||
pub drop_count: i32,
|
||||
}
|
||||
|
||||
pub async fn create_course(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
@@ -149,8 +169,11 @@ pub async fn create_lesson(
|
||||
let transcription = payload.get("transcription").cloned();
|
||||
let metadata = payload.get("metadata").cloned();
|
||||
|
||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *"
|
||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *"
|
||||
)
|
||||
.bind(module_id)
|
||||
.bind(title)
|
||||
@@ -159,6 +182,8 @@ pub async fn create_lesson(
|
||||
.bind(position)
|
||||
.bind(transcription)
|
||||
.bind(metadata)
|
||||
.bind(is_graded)
|
||||
.bind(grading_category_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -226,29 +251,95 @@ pub async fn update_lesson(
|
||||
let content_type = payload.get("content_type").and_then(|t| t.as_str());
|
||||
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
||||
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
|
||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
|
||||
let grading_category_id = payload.get("grading_category_id")
|
||||
.and_then(|v| {
|
||||
if v.is_null() {
|
||||
Some(None)
|
||||
} else {
|
||||
v.as_str().and_then(|s| Uuid::parse_str(s).ok()).map(Some)
|
||||
}
|
||||
});
|
||||
|
||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||
"UPDATE lessons
|
||||
SET title = COALESCE($1, title),
|
||||
content_type = COALESCE($2, content_type),
|
||||
content_url = COALESCE($3, content_url),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5 RETURNING *"
|
||||
position = COALESCE($4, position),
|
||||
is_graded = COALESCE($5, is_graded),
|
||||
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END
|
||||
WHERE id = $8 RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content_type)
|
||||
.bind(content_url)
|
||||
.bind(position)
|
||||
.bind(is_graded)
|
||||
.bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
|
||||
.bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()))
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|e| {
|
||||
tracing::error!("Update lesson failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await;
|
||||
|
||||
Ok(Json(updated_lesson))
|
||||
}
|
||||
|
||||
// Grading Policies
|
||||
pub async fn get_grading_categories(
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<common::models::GradingCategory>>, (StatusCode, String)> {
|
||||
let categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at"
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(categories))
|
||||
}
|
||||
|
||||
pub async fn create_grading_category(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<GradingPayload>,
|
||||
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
||||
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(payload.course_id)
|
||||
.bind(payload.name)
|
||||
.bind(payload.weight)
|
||||
.bind(payload.drop_count)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(category))
|
||||
}
|
||||
|
||||
pub async fn delete_grading_category(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
sqlx::query("DELETE FROM grading_categories WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn log_action(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -392,3 +483,69 @@ pub async fn upload_asset(
|
||||
url,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub full_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||
|
||||
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("User").to_string());
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *"
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.bind(password_hash)
|
||||
.bind(full_name)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||
|
||||
let token = create_jwt(user.id, "instructor")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
user: UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&payload.email)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||
|
||||
if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||
}
|
||||
|
||||
let token = create_jwt(user.id, "instructor")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
user: UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
mod handlers;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{get, post, delete},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
@@ -41,6 +41,11 @@ async fn main() {
|
||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
||||
.route("/auth/register", post(handlers::register))
|
||||
.route("/auth/login", post(handlers::login))
|
||||
.route("/grading", post(handlers::create_grading_category))
|
||||
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
||||
.route("/courses/{id}/grading", get(handlers::get_grading_categories))
|
||||
.route("/assets/upload", post(handlers::upload_asset))
|
||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||
.layer(cors)
|
||||
|
||||
@@ -17,3 +17,5 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dotenvy.workspace = true
|
||||
tower-http.workspace = true
|
||||
bcrypt.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM rustlang/rust:nightly as builder
|
||||
FROM rustlang/rust:nightly AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Create users table for Students
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Note: Enrollments already exist and use user_id.
|
||||
-- We should ideally link them now.
|
||||
ALTER TABLE enrollments ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Add grading categories table
|
||||
CREATE TABLE grading_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
weight INTEGER NOT NULL CHECK (weight >= 0 AND weight <= 100),
|
||||
drop_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Update lessons with grading fields
|
||||
ALTER TABLE lessons ADD COLUMN grading_category_id UUID REFERENCES grading_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE lessons ADD COLUMN is_graded BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Create table to track individual student scores
|
||||
CREATE TABLE user_grades (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
score FLOAT4 NOT NULL, -- 0.0 to 1.0 (percentage)
|
||||
metadata JSONB, -- store specific answers or feedback
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, lesson_id)
|
||||
);
|
||||
@@ -3,9 +3,12 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Enrollment, Module, Lesson};
|
||||
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse};
|
||||
use common::auth::create_jwt;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
pub async fn enroll_user(
|
||||
State(pool): State<PgPool>,
|
||||
@@ -13,7 +16,8 @@ pub async fn enroll_user(
|
||||
) -> Result<Json<Enrollment>, StatusCode> {
|
||||
let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let user_id = Uuid::new_v4(); // Placeholder for actual auth
|
||||
let user_id_str = payload.get("user_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let user_id = Uuid::parse_str(user_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let enrollment = sqlx::query_as::<_, Enrollment>(
|
||||
"INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2) RETURNING *"
|
||||
@@ -22,11 +26,89 @@ pub async fn enroll_user(
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|e| {
|
||||
tracing::error!("Enrollment failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(enrollment))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub full_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GradeSubmissionPayload {
|
||||
pub user_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub score: f32,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||
|
||||
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("Student").to_string());
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *"
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.bind(password_hash)
|
||||
.bind(full_name)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||
|
||||
let token = create_jwt(user.id, "student")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
user: UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&payload.email)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||
|
||||
if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||
}
|
||||
|
||||
let token = create_jwt(user.id, "student")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
user: UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_course_catalog(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||
@@ -70,15 +152,37 @@ pub async fn ingest_course(
|
||||
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
|
||||
// 2. Clear existing grading categories, modules and lessons (cascading handles lessons/categories)
|
||||
sqlx::query("DELETE FROM grading_categories WHERE course_id = $1")
|
||||
.bind(payload.course.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
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
|
||||
// 3. Insert Grading Categories
|
||||
for cat in payload.grading_categories {
|
||||
sqlx::query(
|
||||
"INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(cat.id)
|
||||
.bind(payload.course.id)
|
||||
.bind(&cat.name)
|
||||
.bind(cat.weight)
|
||||
.bind(cat.drop_count)
|
||||
.bind(cat.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
// 4. Insert Modules and Lessons
|
||||
for pub_module in payload.modules {
|
||||
sqlx::query(
|
||||
"INSERT INTO modules (id, course_id, title, position, created_at)
|
||||
@@ -95,8 +199,8 @@ pub async fn ingest_course(
|
||||
|
||||
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)"
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
|
||||
)
|
||||
.bind(lesson.id)
|
||||
.bind(pub_module.module.id)
|
||||
@@ -107,9 +211,14 @@ pub async fn ingest_course(
|
||||
.bind(&lesson.metadata)
|
||||
.bind(lesson.position)
|
||||
.bind(lesson.created_at)
|
||||
.bind(lesson.is_graded)
|
||||
.bind(lesson.grading_category_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to insert lesson: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +245,17 @@ pub async fn get_course_outline(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut pub_modules = Vec::new();
|
||||
// 3. Fetch Grading Categories
|
||||
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 3. Fetch Lessons
|
||||
// 4. Fetch Lessons
|
||||
let mut pub_modules = Vec::new();
|
||||
for module in modules {
|
||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||
.bind(module.id)
|
||||
@@ -154,6 +271,7 @@ pub async fn get_course_outline(
|
||||
|
||||
Ok(Json(common::models::PublishedCourse {
|
||||
course,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
}))
|
||||
}
|
||||
@@ -170,3 +288,57 @@ pub async fn get_lesson_content(
|
||||
|
||||
Ok(Json(lesson))
|
||||
}
|
||||
|
||||
pub async fn get_user_enrollments(
|
||||
State(pool): State<PgPool>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
||||
let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(enrollments))
|
||||
}
|
||||
|
||||
pub async fn submit_lesson_score(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<GradeSubmissionPayload>,
|
||||
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
||||
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
||||
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
metadata = EXCLUDED.metadata,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(payload.user_id)
|
||||
.bind(payload.course_id)
|
||||
.bind(payload.lesson_id)
|
||||
.bind(payload.score)
|
||||
.bind(payload.metadata)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(grade))
|
||||
}
|
||||
|
||||
pub async fn get_user_course_grades(
|
||||
State(pool): State<PgPool>,
|
||||
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
||||
let grades = sqlx::query_as::<_, common::models::UserGrade>(
|
||||
"SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(grades))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::net::SocketAddr;
|
||||
use dotenvy::dotenv;
|
||||
@@ -27,12 +28,23 @@ async fn main() {
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/catalog", get(handlers::get_course_catalog))
|
||||
.route("/enroll", post(handlers::enroll_user))
|
||||
.route("/ingest", post(handlers::ingest_course))
|
||||
.route("/auth/register", post(handlers::register))
|
||||
.route("/auth/login", post(handlers::login))
|
||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||
|
||||
Reference in New Issue
Block a user