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
+230 -4
View File
@@ -11,6 +11,30 @@ use common::models::{
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
};
pub async fn get_me(
claims: common::auth::Claims,
State(pool): State<PgPool>,
) -> Result<Json<UserResponse>, (StatusCode, String)> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(UserResponse {
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
organization_id: user.organization_id,
xp: user.xp,
level: user.level,
avatar_url: user.avatar_url,
bio: user.bio,
language: user.language,
}))
}
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use std::env;
@@ -644,6 +668,12 @@ pub async fn ingest_course(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
sqlx::query("DELETE FROM course_instructors WHERE course_id = $1")
.bind(payload.course.id)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 3. Insert Grading Categories
for cat in payload.grading_categories {
sqlx::query(
@@ -662,6 +692,27 @@ pub async fn ingest_course(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
// 4. Insert Instructors
if let Some(instructors) = payload.instructors {
for instructor in instructors {
sqlx::query(
"INSERT INTO course_instructors (id, course_id, user_id, role, created_at)
VALUES ($1, $2, $3, $4, $5)"
)
.bind(instructor.id)
.bind(payload.course.id)
.bind(instructor.user_id)
.bind(&instructor.role)
.bind(instructor.created_at)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to insert instructor: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
}
}
// 4. Insert Modules and Lessons
for pub_module in &payload.modules {
sqlx::query(
@@ -680,8 +731,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, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
)
.bind(lesson.id)
.bind(pub_module.module.id)
@@ -701,6 +752,7 @@ pub async fn ingest_course(
.bind(lesson.due_date)
.bind(&lesson.important_date_type)
.bind(&lesson.transcription_status)
.bind(lesson.is_previewable)
.execute(&mut *tx)
.await
.map_err(|e| {
@@ -858,11 +910,23 @@ pub async fn get_course_outline(
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 7. Fetch Course Team
let instructors = sqlx::query_as::<_, common::models::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)?;
Ok(Json(common::models::PublishedCourse {
course,
organization,
grading_categories,
modules: pub_modules,
instructors: Some(instructors),
dependencies: Some(dependencies),
}))
}
@@ -903,8 +967,8 @@ pub async fn get_lesson_content(
sqlx::query_as::<_, Lesson>(
"SELECT l.* FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN enrollments e ON m.course_id = e.course_id
WHERE l.id = $1 AND e.user_id = $2",
LEFT JOIN enrollments e ON m.course_id = e.course_id AND e.user_id = $2
WHERE l.id = $1 AND (e.id IS NOT NULL OR l.is_previewable = true)",
)
.bind(id)
.bind(claims.sub)
@@ -1363,6 +1427,95 @@ pub async fn get_course_analytics(
}))
}
pub async fn get_student_progress_stats(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<common::models::ProgressStats>, (StatusCode, String)> {
let user_id = claims.sub;
// 1. Total Lessons
let total_lessons: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)"
)
.bind(org_ctx.id)
.bind(course_id)
.fetch_one(&pool)
.await
.unwrap_or(0);
// 2. Completed Lessons
let completed_lessons: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3",
)
.bind(user_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.unwrap_or(0);
// 3. Daily Progress (Last 30 days)
let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
r#"
SELECT
TO_CHAR(created_at, 'YYYY-MM-DD') as date,
COUNT(*)::bigint as count
FROM user_grades
WHERE user_id = $1 AND course_id = $2 AND organization_id = $3
AND created_at >= NOW() - INTERVAL '30 days'
GROUP BY date
ORDER BY date ASC
"#
)
.bind(user_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.unwrap_or_default();
// 4. Prediction Logic
let first_entry: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
"SELECT MIN(created_at) FROM user_grades WHERE user_id = $1 AND course_id = $2"
)
.bind(user_id)
.bind(course_id)
.fetch_one(&pool)
.await
.unwrap_or(None);
let estimated_completion_date = if let Some(start) = first_entry {
let days_passed = (chrono::Utc::now() - start).num_days().max(1) as f64;
let pace = completed_lessons as f64 / days_passed;
if pace > 0.0 && total_lessons > completed_lessons {
let remaining = (total_lessons - completed_lessons) as f64;
let days_to_finish = (remaining / pace).ceil() as i64;
Some(chrono::Utc::now() + chrono::Duration::days(days_to_finish))
} else {
None
}
} else {
None
};
let progress_percentage = if total_lessons > 0 {
(completed_lessons as f32 / total_lessons as f32) * 100.0
} else {
0.0
};
Ok(Json(common::models::ProgressStats {
total_lessons,
completed_lessons,
progress_percentage,
daily_completions,
estimated_completion_date,
}))
}
pub async fn get_advanced_analytics(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -1524,6 +1677,79 @@ pub async fn check_deadlines_and_notify(pool: PgPool) {
}
}
pub async fn toggle_bookmark(
Org(org_ctx): Org,
claims: Claims,
Path(lesson_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = claims.sub;
// 1. Get course_id from lesson
let course_id: Uuid = sqlx::query_scalar(
"SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1"
)
.bind(lesson_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?;
// 2. Check if already bookmarked
let existing_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_bookmarks WHERE user_id = $1 AND lesson_id = $2"
)
.bind(user_id)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(id) = existing_id {
// Remove bookmark
sqlx::query("DELETE FROM user_bookmarks WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
} else {
// Add bookmark
sqlx::query(
"INSERT INTO user_bookmarks (organization_id, user_id, course_id, lesson_id) VALUES ($1, $2, $3, $4)"
)
.bind(org_ctx.id)
.bind(user_id)
.bind(course_id)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::CREATED)
}
}
pub async fn get_user_bookmarks(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filter): Query<common::models::AnalyticsFilter>,
) -> Result<Json<Vec<common::models::UserBookmark>>, (StatusCode, String)> {
let user_id = claims.sub;
let bookmarks = sqlx::query_as::<_, common::models::UserBookmark>(
"SELECT * FROM user_bookmarks WHERE user_id = $1 AND organization_id = $2 AND ($3::uuid IS NULL OR course_id = $3) ORDER BY created_at DESC"
)
.bind(user_id)
.bind(org_ctx.id)
.bind(filter.cohort_id) // Reusing AnalyticsFilter which has cohort_id, but here we can use it for course_id or just ignore it.
// Wait, let's create a better filter for this.
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(bookmarks))
}
pub async fn update_user(
Org(org_ctx): Org,
claims: common::auth::Claims,
@@ -329,10 +329,10 @@ pub async fn create_post(
.bind(thread_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?;
if thread.0 {
return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string()));
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
}
let post = sqlx::query_as::<_, DiscussionPost>(
@@ -392,7 +392,7 @@ pub async fn vote_post(
Json(payload): Json<VotePayload>,
) -> Result<StatusCode, (StatusCode, String)> {
if payload.vote_type != "upvote" && payload.vote_type != "downvote" {
return Err((StatusCode::BAD_REQUEST, "Invalid vote type".to_string()));
return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string()));
}
// Upsert vote
+255
View File
@@ -0,0 +1,255 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::{Redirect},
Form,
};
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
use serde::{Deserialize};
use sqlx::{PgPool};
use uuid::Uuid;
use common::models::{LtiLaunchClaims, LtiRegistration, LtiResourceLink, User};
use common::auth::Claims;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
#[derive(Deserialize)]
pub struct LtiLoginParams {
pub iss: String,
pub login_hint: String,
pub target_link_uri: String,
pub lti_message_hint: Option<String>,
pub client_id: Option<String>,
pub lti_deployment_id: Option<String>,
}
pub async fn lti_login_initiation(
State(pool): State<PgPool>,
Query(params): Query<LtiLoginParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Find registration
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
)
.bind(&params.iss)
.bind(&params.client_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?;
// 2. Generate state and nonce
let state = Uuid::new_v4().to_string();
let nonce = Uuid::new_v4().to_string();
// 3. Store nonce
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
.bind(&nonce)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Construct redirect URL
let mut url = format!(
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
registration.auth_login_url,
registration.client_id,
urlencoding::encode(&params.target_link_uri),
urlencoding::encode(&params.login_hint),
state,
nonce
);
if let Some(hint) = params.lti_message_hint {
url.push_str(&format!("&lti_message_hint={}", urlencoding::encode(&hint)));
}
Ok(Redirect::to(&url))
}
#[derive(Deserialize)]
pub struct LtiLaunchParams {
pub id_token: String,
pub state: String,
}
pub async fn validate_lti_jwt(
id_token: &str,
jwks_url: &str,
client_id: &str,
) -> Result<LtiLaunchClaims, String> {
let header = decode_header(id_token).map_err(|e| e.to_string())?;
let kid = header.kid.ok_or("Missing kid in JWT header")?;
// Fetch JWKS
let jwks: JwkSet = reqwest::get(jwks_url)
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?;
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
validation.set_audience(&[client_id]);
let token_data = decode::<LtiLaunchClaims>(id_token, &decoding_key, &validation)
.map_err(|e| e.to_string())?;
Ok(token_data.claims)
}
pub async fn lti_launch(
State(pool): State<PgPool>,
Form(payload): Form<LtiLaunchParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Decode claims manually to find registration (since we don't have the key yet)
let parts: Vec<&str> = payload.id_token.split('.').collect();
if parts.len() != 3 {
return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string()));
}
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?;
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?;
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?;
let aud_val = &claims["aud"];
let aud = match aud_val {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?,
_ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())),
};
// 2. Find registration
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
)
.bind(iss)
.bind(aud)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?;
// 3. Validate JWT
let lti_claims = validate_lti_jwt(&payload.id_token, &registration.jwks_url, &registration.client_id)
.await
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?;
// 4. Verify nonce
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
.bind(&lti_claims.nonce)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.rows_affected() > 0;
if !nonce_exists {
return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string()));
}
// 5. Find or create user
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
let mut user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE email = $1 AND organization_id = $2"
)
.bind(&email)
.bind(registration.organization_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if user.is_none() {
let new_user_id = Uuid::new_v4();
let role = if lti_claims.roles.iter().any(|r| r.contains("Instructor") || r.contains("Administrator")) {
"instructor"
} else {
"student"
};
sqlx::query(
"INSERT INTO users (id, organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(new_user_id)
.bind(registration.organization_id)
.bind(&email)
.bind("")
.bind(&full_name)
.bind(role)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
user = Some(User {
id: new_user_id,
organization_id: registration.organization_id,
email: email.clone(),
password_hash: "".to_string(),
full_name: full_name.clone(),
role: role.to_string(),
xp: 0,
level: 1,
avatar_url: None,
bio: None,
language: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
});
}
let user = user.unwrap();
// 6. Map resource link to course
let resource_link = sqlx::query_as::<_, LtiResourceLink>(
"SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2"
)
.bind(registration.organization_id)
.bind(&lti_claims.resource_link.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let redirect_target = if let Some(link) = resource_link {
sqlx::query(
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
)
.bind(user.id)
.bind(registration.organization_id)
.bind(link.course_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
format!("/courses/{}", link.course_id)
} else {
"/dashboard".to_string()
};
// 7. Generate JWT
let claims = Claims {
sub: user.id,
role: user.role,
org: user.organization_id,
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
course_id: None,
token_type: Some("access".to_string()),
};
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 8. Redirect to Experience app launch page
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
}
+7
View File
@@ -6,6 +6,7 @@ mod handlers_discussions;
mod handlers_notes;
mod handlers_payments;
mod handlers_peer_review;
mod lti;
use axum::{
Router, middleware,
@@ -50,6 +51,7 @@ async fn main() {
.allow_headers(Any);
let protected_routes = Router::new()
.route("/auth/me", get(handlers::get_me))
.route("/enroll", post(handlers::enroll_user))
.route("/bulk-enroll", post(handlers::bulk_enroll_users))
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
@@ -58,7 +60,10 @@ async fn main() {
post(handlers_payments::create_payment_preference),
)
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
.route("/bookmarks", get(handlers::get_user_bookmarks))
.route("/grades", post(handlers::submit_lesson_score))
.route(
"/users/{user_id}/courses/{course_id}/grades",
@@ -203,6 +208,8 @@ async fn main() {
"/payments/mercadopago/webhook",
post(handlers_payments::mercadopago_webhook),
)
.route("/lti/login", get(lti::lti_login_initiation))
.route("/lti/launch", post(lti::lti_launch))
.merge(protected_routes)
.layer(cors)
.with_state(pool);