feat: Add LTI launch, lesson preview, course progress, bookmarks, and asset management features.

This commit is contained in:
2026-02-23 15:43:45 -03:00
parent f365e585a2
commit 7f7ea3d70c
45 changed files with 5250 additions and 697 deletions
+42 -207
View File
@@ -12,7 +12,7 @@ use common::auth::{Claims, create_jwt, create_preview_token};
use common::middleware::Org;
use common::models::{
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
PublishedModule, User, UserResponse,
PublishedModule, User, UserResponse, CourseInstructor,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -115,11 +115,23 @@ pub async fn publish_course(
let mut course_for_pub = course.clone();
course_for_pub.organization_id = target_org_id;
// 5. Fetch Course Team
let instructors = sqlx::query_as::<_, CourseInstructor>(
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
JOIN users u ON ci.user_id = u.id
WHERE ci.course_id = $1"
)
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let payload = PublishedCourse {
course: course_for_pub,
organization,
grading_categories,
modules: pub_modules,
instructors: Some(instructors),
dependencies: None,
};
@@ -329,10 +341,10 @@ pub async fn update_course(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?;
if claims.role != "admin" && existing.instructor_id != claims.sub {
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
return Err((StatusCode::FORBIDDEN, "No autorizado".into()));
}
let title = payload
@@ -547,6 +559,11 @@ pub async fn create_lesson(
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
let is_previewable = payload
.get("is_previewable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut tx = pool
.begin()
.await
@@ -575,7 +592,7 @@ pub async fn create_lesson(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let lesson = sqlx::query_as::<_, Lesson>(
"SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"
"SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"
)
.bind(org_ctx.id)
.bind(module_id)
@@ -591,6 +608,7 @@ pub async fn create_lesson(
.bind(allow_retry)
.bind(due_date)
.bind(important_date_type)
.bind(is_previewable)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
@@ -1293,6 +1311,7 @@ pub async fn update_lesson(
let metadata = payload.get("metadata").cloned();
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
let summary = payload.get("summary").and_then(|v| v.as_str());
let is_previewable = payload.get("is_previewable").and_then(|v| v.as_bool());
let content_blocks = payload.get("content_blocks").cloned();
let transcription = payload.get("transcription").cloned();
@@ -1342,7 +1361,7 @@ pub async fn update_lesson(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let lesson = sqlx::query_as::<_, Lesson>(
"SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
"SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
)
.bind(id)
.bind(org_ctx.id)
@@ -1360,6 +1379,7 @@ pub async fn update_lesson(
.bind(due_date)
.bind(important_date_type)
.bind(summary)
.bind(is_previewable)
.bind(clear_due_date)
.bind(clear_grading_category)
.fetch_one(&mut *tx)
@@ -1642,195 +1662,6 @@ pub async fn reorder_lessons(
Ok(StatusCode::OK)
}
#[derive(Debug, Serialize)]
pub struct UploadResponse {
pub id: Uuid,
pub filename: String,
pub url: String,
}
pub async fn upload_asset(
Org(org_ctx): Org,
State(pool): State<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
tracing::info!("Starting upload_asset for org: {}", org_ctx.id);
let mut filename = String::new();
let mut data = Vec::new();
let mut mimetype = String::new();
let mut course_id: Option<Uuid> = None;
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();
} else if name == "course_id" {
if let Ok(txt) = field.text().await {
if let Ok(id) = Uuid::parse_str(&txt) {
course_id = Some(id);
}
}
}
}
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, organization_id, course_id) VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(asset_id)
.bind(&filename)
.bind(storage_path)
.bind(mimetype)
.bind(size_bytes)
.bind(org_ctx.id)
.bind(course_id)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let url = format!("/assets/{}", storage_filename);
tracing::info!("Upload successful: {} -> {}", filename, url);
Ok(Json(UploadResponse {
id: asset_id,
filename,
url,
}))
}
pub async fn get_course_assets(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<common::models::Asset>>, StatusCode> {
let assets = sqlx::query_as::<_, common::models::Asset>(
"SELECT * FROM assets WHERE organization_id = $1 AND course_id = $2 ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch course assets: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(assets))
}
pub async fn delete_asset(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(asset_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Fetch asset to verify ownership/org
let asset = sqlx::query_as::<_, common::models::Asset>(
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
)
.bind(asset_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let asset = match asset {
Some(a) => a,
None => return Err((StatusCode::NOT_FOUND, "Asset not found".to_string())),
};
// 2. Check permissions (only instructor of the course or admin)
if claims.role != "admin" {
// If linked to a course, check if user owns that course
if let Some(cid) = asset.course_id {
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(cid)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(c) = course {
if c.instructor_id != claims.sub {
return Err((
StatusCode::FORBIDDEN,
"Not authorized to delete this asset".to_string(),
));
}
}
}
// If not linked to a course, only admins might delete? Or maybe uploader?
// For now, let's assume if it's orphaned, only admin deletes.
if asset.course_id.is_none() {
return Err((
StatusCode::FORBIDDEN,
"Only admins can delete global assets".to_string(),
));
}
}
// 3. Delete file
// Note: storage_path is relative to working dir usually "uploads/..."
if let Err(e) = tokio::fs::remove_file(&asset.storage_path).await {
tracing::warn!("Failed to delete file {}: {}", asset.storage_path, e);
// We continue to delete from DB even if file specific deletion failed (maybe already gone)
}
// 4. Delete from DB
sqlx::query("DELETE FROM assets WHERE id = $1")
.bind(asset_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct AuthPayload {
pub email: String,
@@ -1988,7 +1819,7 @@ pub async fn login(
"Verification failed".into(),
)
})? {
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
return Err((StatusCode::UNAUTHORIZED, "Credenciales inválidas".into()));
}
let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| {
@@ -3059,7 +2890,7 @@ pub async fn export_course(
})?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Course not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()));
}
// 2. Generate ZIP
@@ -3143,8 +2974,8 @@ pub async fn import_course(
let mimetype = mime_guess::from_path(&old_filename).first_or_octet_stream().to_string();
sqlx::query(
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id)
VALUES ($1, $2, $3, $4, $5, $6)"
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, uploaded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(new_id)
.bind(&old_filename)
@@ -3152,6 +2983,7 @@ pub async fn import_course(
.bind(&mimetype)
.bind(content.len() as i64)
.bind(org_ctx.id)
.bind(claims.sub)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -3312,7 +3144,7 @@ pub async fn check_course_access(
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CourseInstructor {
pub struct CourseInstructorDetail {
pub id: Uuid,
pub course_id: Uuid,
pub user_id: Uuid,
@@ -3326,18 +3158,21 @@ pub async fn get_course_team(
Org(_org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<CourseInstructor>>, (StatusCode, String)> {
if !check_course_access(&pool, id, claims.sub, &claims.role).await? {
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<CourseInstructorDetail>>, (StatusCode, String)> {
if !check_course_access(&pool, course_id, claims.sub, &claims.role).await? {
return Err((StatusCode::FORBIDDEN, "No access to this course team".into()));
}
let team = sqlx::query_as::<_, CourseInstructor>(
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
JOIN users u ON ci.user_id = u.id
WHERE ci.course_id = $1"
let team = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT ci.id, ci.course_id, ci.user_id, ci.role, ci.created_at, u.email, u.full_name
FROM course_instructors ci
JOIN users u ON ci.user_id = u.id
WHERE ci.course_id = $1
"#
)
.bind(id)
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+206
View File
@@ -0,0 +1,206 @@
use axum::{
Json,
extract::{Path, Query, State, Multipart},
http::StatusCode,
};
use common::models::{Asset};
use common::{auth::Claims, middleware::Org};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use std::path::Path as StdPath;
#[derive(Debug, Serialize)]
pub struct AssetUploadResponse {
pub id: Uuid,
pub filename: String,
pub url: String,
pub mimetype: String,
pub size_bytes: i64,
}
#[derive(Debug, Deserialize)]
pub struct AssetFilters {
pub mimetype: Option<String>,
pub course_id: Option<Uuid>,
pub search: Option<String>,
pub page: Option<u32>,
pub limit: Option<u32>,
}
/// POST /api/assets/upload - Subir un archivo a la biblioteca global
pub async fn upload_asset(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
mut multipart: Multipart,
) -> Result<Json<AssetUploadResponse>, (StatusCode, String)> {
let mut filename = String::new();
let mut data = Vec::new();
let mut mimetype = String::new();
let mut course_id: Option<Uuid> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (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| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_vec();
} else if name == "course_id" {
if let Ok(txt) = field.text().await {
if let Ok(id) = Uuid::parse_str(&txt) {
course_id = Some(id);
}
}
}
}
if data.is_empty() {
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
}
let asset_id = Uuid::new_v4();
let extension = StdPath::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| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Write file
tokio::fs::write(&storage_path, data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let size_bytes = tokio::fs::metadata(&storage_path)
.await
.map(|m| m.len() as i64)
.unwrap_or(0);
// Record in DB
sqlx::query!(
r#"
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
asset_id,
org_ctx.id,
claims.sub,
course_id,
filename,
storage_path,
mimetype,
size_bytes
)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AssetUploadResponse {
id: asset_id,
filename,
url: format!("/assets/{}", storage_filename),
mimetype,
size_bytes,
}))
}
/// GET /api/assets - Listar activos de la organización
pub async fn list_assets(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<AssetFilters>,
) -> Result<Json<Vec<Asset>>, (StatusCode, String)> {
let limit = filters.limit.unwrap_or(50) as i64;
let offset = ((filters.page.unwrap_or(1).max(1) - 1) * filters.limit.unwrap_or(50)) as i64;
let mut query = String::from("SELECT * FROM assets WHERE organization_id = $1");
let mut param_index = 2;
if filters.mimetype.is_some() {
query.push_str(&format!(" AND mimetype ILIKE ${}", param_index));
param_index += 1;
}
if filters.course_id.is_some() {
query.push_str(&format!(" AND course_id = ${}", param_index));
param_index += 1;
}
if filters.search.is_some() {
query.push_str(&format!(" AND filename ILIKE ${}", param_index));
param_index += 1;
}
query.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", param_index, param_index + 1));
let mut sql_query = sqlx::query_as::<_, Asset>(&query).bind(org_ctx.id);
if let Some(mt) = &filters.mimetype {
sql_query = sql_query.bind(format!("%{}%", mt));
}
if let Some(cid) = filters.course_id {
sql_query = sql_query.bind(cid);
}
if let Some(search) = &filters.search {
sql_query = sql_query.bind(format!("%{}%", search));
}
let assets = sql_query
.bind(limit)
.bind(offset)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(assets))
}
/// DELETE /api/assets/:id - Eliminar un activo y su archivo físico
pub async fn delete_asset(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Get asset metadata to find file path
let asset = sqlx::query_as!(
Asset,
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
id,
org_ctx.id
)
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
// 2. Delete from DB
sqlx::query!("DELETE FROM assets WHERE id = $1", id)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Delete physical file (async)
let _ = tokio::fs::remove_file(&asset.storage_path).await;
Ok(StatusCode::NO_CONTENT)
}
+4 -3
View File
@@ -3,6 +3,7 @@ pub mod exporter;
mod external_handlers;
mod handlers;
mod handlers_branding;
mod handlers_assets;
mod handlers_dependencies;
mod handlers_library;
mod handlers_rubrics;
@@ -161,9 +162,9 @@ async fn main() {
.route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/audit-logs", get(handlers::get_audit_logs))
.route("/api/ai/review-text", post(handlers::review_text))
.route("/api/assets/upload", post(handlers::upload_asset))
.route("/api/assets/{id}", delete(handlers::delete_asset))
.route("/courses/{id}/assets", get(handlers::get_course_assets))
.route("/api/assets", get(handlers_assets::list_assets))
.route("/api/assets/upload", post(handlers_assets::upload_asset))
.route("/api/assets/{id}", delete(handlers_assets::delete_asset))
.layer(DefaultBodyLimit::disable())
.route(
"/organizations",