Initial commit: Clean workspace without heavy binaries
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
use axum::{
|
||||
extract::{State, Path, Query},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Module, Lesson};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ModuleQuery {
|
||||
pub course_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LessonQuery {
|
||||
pub module_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn create_course(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<Course>, StatusCode> {
|
||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let instructor_id = Uuid::new_v4();
|
||||
|
||||
let course = sqlx::query_as::<_, Course>(
|
||||
"INSERT INTO courses (title, instructor_id) VALUES ($1, $2) RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(instructor_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, instructor_id, "CREATE", "Course", course.id, json!({ "title": title })).await;
|
||||
|
||||
Ok(Json(course))
|
||||
}
|
||||
|
||||
pub async fn get_courses(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(courses))
|
||||
}
|
||||
|
||||
pub async fn create_module(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<Module>, StatusCode> {
|
||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
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 position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
|
||||
let module = sqlx::query_as::<_, Module>(
|
||||
"INSERT INTO modules (course_id, title, position) VALUES ($1, $2, $3) RETURNING *"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(title)
|
||||
.bind(position)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "CREATE", "Module", module.id, json!({ "title": title, "course_id": course_id })).await;
|
||||
|
||||
Ok(Json(module))
|
||||
}
|
||||
|
||||
pub async fn create_lesson(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let module_id_str = payload.get("module_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let module_id = Uuid::parse_str(module_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let content_type = payload.get("content_type").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let content_url = payload.get("content_url").and_then(|v| v.as_str());
|
||||
let position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let transcription = payload.get("transcription").cloned();
|
||||
let metadata = payload.get("metadata").cloned();
|
||||
|
||||
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 *"
|
||||
)
|
||||
.bind(module_id)
|
||||
.bind(title)
|
||||
.bind(content_type)
|
||||
.bind(content_url)
|
||||
.bind(position)
|
||||
.bind(transcription)
|
||||
.bind(metadata)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "CREATE", "Lesson", lesson.id, json!({ "title": title, "module_id": module_id })).await;
|
||||
|
||||
Ok(Json(lesson))
|
||||
}
|
||||
|
||||
pub async fn process_transcription(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let _lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Simulate AI Processing
|
||||
let mock_transcription = json!({
|
||||
"en": "This is a simulated transcription of the video content in English.",
|
||||
"es": "Esta es una transcripción simulada del contenido del video en español.",
|
||||
"cues": [
|
||||
{ "start": 0.0, "end": 2.0, "text": "Hello world!" },
|
||||
{ "start": 2.1, "end": 5.0, "text": "Welcome to OpenCCB." }
|
||||
]
|
||||
});
|
||||
|
||||
// 3. Update lesson
|
||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(mock_transcription)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "TRANSCRIPTION_PROCESSED", "Lesson", id, json!({})).await;
|
||||
|
||||
Ok(Json(updated_lesson))
|
||||
}
|
||||
|
||||
pub async fn get_lesson(
|
||||
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))
|
||||
}
|
||||
|
||||
pub async fn update_lesson(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
let title = payload.get("title").and_then(|t| t.as_str());
|
||||
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 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 *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content_type)
|
||||
.bind(content_url)
|
||||
.bind(position)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await;
|
||||
|
||||
Ok(Json(updated_lesson))
|
||||
}
|
||||
|
||||
async fn log_action(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
action: &str,
|
||||
entity_type: &str,
|
||||
entity_id: Uuid,
|
||||
changes: serde_json::Value,
|
||||
) {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO audit_logs (user_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(action)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.bind(changes)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn get_course(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Course>, StatusCode> {
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(course))
|
||||
}
|
||||
|
||||
pub async fn get_modules(
|
||||
State(pool): State<PgPool>,
|
||||
Query(query): Query<ModuleQuery>,
|
||||
) -> Result<Json<Vec<Module>>, StatusCode> {
|
||||
let modules = match query.course_id {
|
||||
Some(course_id) => {
|
||||
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Module>("SELECT * FROM modules ORDER BY position")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(modules))
|
||||
}
|
||||
|
||||
pub async fn get_lessons(
|
||||
State(pool): State<PgPool>,
|
||||
Query(query): Query<LessonQuery>,
|
||||
) -> Result<Json<Vec<Lesson>>, StatusCode> {
|
||||
let lessons = match query.module_id {
|
||||
Some(module_id) => {
|
||||
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||
.bind(module_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons ORDER BY position")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(lessons))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub async fn upload_asset(
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
||||
let mut filename = String::new();
|
||||
let mut data = Vec::new();
|
||||
let mut mimetype = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "file" {
|
||||
filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||
mimetype = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||
data = field.bytes().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
|
||||
}
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
let extension = std::path::Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let storage_filename = format!("{}.{}", asset_id, extension);
|
||||
let storage_path = format!("uploads/{}", storage_filename);
|
||||
|
||||
// Ensure uploads directory exists
|
||||
tokio::fs::create_dir_all("uploads").await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Write file
|
||||
tokio::fs::write(&storage_path, data).await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Record in DB
|
||||
let size_bytes = tokio::fs::metadata(&storage_path).await.map(|m| m.len() as i64).unwrap_or(0);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes) VALUES ($1, $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(&filename)
|
||||
.bind(storage_path)
|
||||
.bind(mimetype)
|
||||
.bind(size_bytes)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let url = format!("/assets/{}", storage_filename);
|
||||
|
||||
Ok(Json(UploadResponse {
|
||||
id: asset_id,
|
||||
filename,
|
||||
url,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
mod handlers;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::net::SocketAddr;
|
||||
use dotenvy::dotenv;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
|
||||
.route("/courses/{id}", get(handlers::get_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))
|
||||
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
||||
.route("/assets/upload", post(handlers::upload_asset))
|
||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
|
||||
tracing::info!("CMS Service listening on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user