feat: update CMS service handlers and main application logic.

This commit is contained in:
2025-12-22 13:54:35 -03:00
parent 57b8d7c0a1
commit 32f71852d9
59 changed files with 9125 additions and 59 deletions
+2
View File
@@ -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 -1
View File
@@ -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)
);
+183 -11
View File
@@ -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))
}
+12
View File
@@ -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));