feat: Implement LTI deep linking, live sessions, predictive analytics, and portfolios with associated UI and database migrations.

This commit is contained in:
2026-02-24 09:37:16 -03:00
parent 7f7ea3d70c
commit 04dbe05704
81 changed files with 4119 additions and 249 deletions
+50 -49
View File
@@ -10,6 +10,7 @@ use common::middleware::Org;
use common::models::{
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
LessonDependency,
};
pub async fn get_me(
@@ -20,7 +21,7 @@ pub async fn get_me(
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(UserResponse {
id: user.id,
@@ -156,7 +157,7 @@ pub async fn export_course_grades(
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Get Student general data
let students = sqlx::query!(
@@ -165,14 +166,14 @@ pub async fn export_course_grades(
u.id,
u.full_name,
u.email,
COALESCE(e.progress, 0)::float4 as progress,
0.0::float4 as progress,
(SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,
AVG(g.score)::float4 as average_score
FROM users u
JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1
LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1
WHERE e.organization_id = $2
GROUP BY u.id, u.full_name, u.email, e.progress
GROUP BY u.id, u.full_name, u.email
ORDER BY u.full_name
"#,
course_id,
@@ -180,7 +181,7 @@ pub async fn export_course_grades(
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Get detailed grades per user/category
struct UserCategoryGrade {
@@ -205,7 +206,7 @@ pub async fn export_course_grades(
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Build CSV
let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string();
@@ -216,7 +217,7 @@ pub async fn export_course_grades(
for s in students {
let cohort = s.cohort_name.unwrap_or_else(|| "N/A".to_string());
let progress = format!("{:.1}%", s.progress * 100.0);
let progress = format!("{:.1}%", s.progress.unwrap_or(0.0) * 100.0);
let overall = s
.average_score
.map(|v| format!("{:.1}%", v * 100.0))
@@ -327,7 +328,7 @@ pub async fn enroll_user(
.bind(course_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Enrollment failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -415,7 +416,7 @@ pub async fn register(
let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let organization = if let Some(org_name) = payload.organization_name {
sqlx::query_as::<_, Organization>(
@@ -424,7 +425,7 @@ pub async fn register(
.bind(&org_name)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
} else {
sqlx::query_as::<_, Organization>(
"SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'",
@@ -448,11 +449,11 @@ pub async fn register(
.bind(organization.id)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
.map_err(|e: sqlx::Error| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
(
@@ -570,7 +571,7 @@ pub async fn get_course_catalog(
.await
}
}
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Catalog fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -612,7 +613,7 @@ pub async fn ingest_course(
.bind(payload.organization.updated_at)
.execute(&mut *tx)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to upsert organization during ingestion: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -650,7 +651,7 @@ pub async fn ingest_course(
.bind(&payload.course.currency)
.execute(&mut *tx)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to upsert course during ingestion: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -706,7 +707,7 @@ pub async fn ingest_course(
.bind(instructor.created_at)
.execute(&mut *tx)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to insert instructor: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -755,7 +756,7 @@ pub async fn ingest_course(
.bind(lesson.is_previewable)
.execute(&mut *tx)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to insert lesson: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -817,7 +818,7 @@ pub async fn get_course_outline(
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
StatusCode::NOT_FOUND
})?;
@@ -830,7 +831,7 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_course_outline: modules fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -844,7 +845,7 @@ pub async fn get_course_outline(
.bind(course.organization_id)
.fetch_one(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!(
"get_course_outline: organization fetch failed for {}: {}",
course.organization_id,
@@ -865,7 +866,7 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -879,7 +880,7 @@ pub async fn get_course_outline(
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!(
"get_course_outline: lessons fetch failed for module {}: {}",
module.id,
@@ -905,7 +906,7 @@ pub async fn get_course_outline(
)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -959,7 +960,7 @@ pub async fn get_lesson_content(
.bind(claims.org)
.fetch_optional(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_lesson_content: DB error (preview): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
@@ -974,7 +975,7 @@ pub async fn get_lesson_content(
.bind(claims.sub)
.fetch_optional(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_lesson_content: DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
@@ -1020,7 +1021,7 @@ pub async fn get_lesson_content(
)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1073,7 +1074,7 @@ pub async fn submit_lesson_score(
let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ip = headers
.get("x-forwarded-for")
@@ -1095,7 +1096,7 @@ pub async fn submit_lesson_score(
Some("SYSTEM_EVENT".to_string()),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 1. Get lesson attempt rules
let max_attempts: Option<Option<i32>> =
@@ -1103,7 +1104,7 @@ pub async fn submit_lesson_score(
.bind(payload.lesson_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if max_attempts.is_none() {
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into()));
@@ -1117,7 +1118,7 @@ pub async fn submit_lesson_score(
.bind(org_ctx.id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(count) = existing_attempts {
if let Some(max) = max_attempts {
@@ -1142,11 +1143,11 @@ pub async fn submit_lesson_score(
.bind(payload.metadata)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Dispatch Webhooks
let webhook_service = common::webhooks::WebhookService::new(pool.clone());
@@ -1254,7 +1255,7 @@ pub async fn get_leaderboard(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to fetch leaderboard: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1310,7 +1311,7 @@ pub async fn get_course_grades(
.bind(filter.cohort_id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to fetch course grades: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
@@ -1362,7 +1363,7 @@ pub async fn get_course_analytics(
.bind(filter.cohort_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Average Course Score (Overall)
let average_score: Option<f32> = sqlx::query_scalar(
@@ -1381,7 +1382,7 @@ pub async fn get_course_analytics(
.bind(filter.cohort_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Per-Lesson Analytics
// Note: We cast AVG to float4 for PostgreSQL compatibility
@@ -1407,7 +1408,7 @@ pub async fn get_course_analytics(
.bind(filter.cohort_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = rows
.into_iter()
@@ -1529,7 +1530,7 @@ pub async fn get_advanced_analytics(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Cohort query failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1542,7 +1543,7 @@ pub async fn get_advanced_analytics(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Retention query failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1572,7 +1573,7 @@ pub async fn record_interaction(
.bind(payload.metadata)
.execute(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to record interaction: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1596,7 +1597,7 @@ pub async fn get_lesson_heatmap(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to fetch heatmap: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1616,7 +1617,7 @@ pub async fn get_notifications(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to fetch notifications: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1638,7 +1639,7 @@ pub async fn mark_notification_as_read(
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| {
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to mark notification as read: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -1702,7 +1703,7 @@ pub async fn toggle_bookmark(
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(id) = existing_id {
// Remove bookmark
@@ -1710,7 +1711,7 @@ pub async fn toggle_bookmark(
.bind(id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
} else {
// Add bookmark
@@ -1723,7 +1724,7 @@ pub async fn toggle_bookmark(
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::CREATED)
}
}
@@ -1745,7 +1746,7 @@ pub async fn get_user_bookmarks(
// Wait, let's create a better filter for this.
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(bookmarks))
}
@@ -1777,7 +1778,7 @@ pub async fn update_user(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(UserResponse {
id: user.id,
@@ -1809,7 +1810,7 @@ pub async fn get_recommendations(
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Fetch lesson metadata (titles and tags) for context
#[derive(sqlx::FromRow)]
@@ -26,7 +26,7 @@ pub async fn submit_assignment(
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(_) = existing {
// Update existing submission
@@ -44,7 +44,7 @@ pub async fn submit_assignment(
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(updated));
}
@@ -65,7 +65,7 @@ pub async fn submit_assignment(
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(submission))
}
@@ -106,7 +106,7 @@ pub async fn get_peer_review_assignment(
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(submission))
}
@@ -125,7 +125,7 @@ pub async fn submit_peer_review(
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let submission = match submission {
Some(s) => s,
@@ -147,7 +147,7 @@ pub async fn submit_peer_review(
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing.is_some() {
return Err((
@@ -172,7 +172,7 @@ pub async fn submit_peer_review(
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(review))
}
@@ -197,7 +197,7 @@ pub async fn get_my_submission_feedback(
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(reviews))
}
+43
View File
@@ -0,0 +1,43 @@
use jsonwebtoken::jwk::{JwkSet, Jwk, CommonParameters, RSAKeyParameters, AlgorithmParameters};
use serde_json::json;
use std::env;
pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey {
let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| {
// Fallback for development (DO NOT USE IN PRODUCTION)
include_str!("../dev_keys/lti_private.pem").to_string()
});
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key")
}
pub fn get_lti_jwks() -> JwkSet {
let n = env::var("LTI_JWK_N").unwrap_or_else(|_| {
"weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCF_VuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw".to_string()
});
let jwk = Jwk {
common: CommonParameters {
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
key_operations: None,
key_algorithm: Some(jsonwebtoken::jwk::KeyAlgorithm::RS256),
key_id: Some("openccb-lti-key-1".to_string()),
x509_url: None,
x509_chain: None,
x509_sha1_fingerprint: None,
x509_sha256_fingerprint: None,
},
algorithm: AlgorithmParameters::RSA(RSAKeyParameters {
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
n,
e: "AQAB".to_string(),
}),
};
JwkSet { keys: vec![jwk] }
}
pub async fn lti_jwks_handler() -> axum::Json<serde_json::Value> {
let jwks = get_lti_jwks();
axum::Json(json!(jwks))
}
+90
View File
@@ -0,0 +1,90 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use common::auth::Claims;
use common::models::Meeting;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CreateMeetingPayload {
pub title: String,
pub description: Option<String>,
pub start_at: chrono::DateTime<Utc>,
pub duration_minutes: i32,
}
pub async fn get_course_meetings(
Path(course_id): Path<Uuid>,
State(pool): State<PgPool>,
claims: Claims,
) -> Result<Json<Vec<Meeting>>, (StatusCode, String)> {
let meetings = sqlx::query_as::<sqlx::Postgres, Meeting>(
"SELECT * FROM meetings WHERE course_id = $1 AND organization_id = $2 ORDER BY start_at ASC"
)
.bind(course_id)
.bind(claims.org)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(meetings))
}
pub async fn create_meeting(
Path(course_id): Path<Uuid>,
State(pool): State<PgPool>,
claims: Claims,
Json(payload): Json<CreateMeetingPayload>,
) -> Result<Json<Meeting>, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string()));
}
let meeting_id = format!("openccb-{}", Uuid::new_v4());
let join_url = format!("https://meet.jit.si/{}", meeting_id);
let meeting = sqlx::query_as::<sqlx::Postgres, Meeting>(
r#"
INSERT INTO meetings (organization_id, course_id, title, description, provider, meeting_id, start_at, duration_minutes, join_url)
VALUES ($1, $2, $3, $4, 'jitsi', $5, $6, $7, $8)
RETURNING *
"#,
)
.bind(claims.org)
.bind(course_id)
.bind(payload.title)
.bind(payload.description)
.bind(meeting_id)
.bind(payload.start_at)
.bind(payload.duration_minutes)
.bind(join_url)
.fetch_one(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(meeting))
}
pub async fn delete_meeting(
Path((_course_id, meeting_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
claims: Claims,
) -> Result<StatusCode, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string()));
}
sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2")
.bind(meeting_id)
.bind(claims.org)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
+91 -34
View File
@@ -205,51 +205,108 @@ pub async fn lti_launch(
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()))?;
// 8. Redirect based on message type
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
let redirect_target = if let Some(link) = resource_link {
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?;
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
if lti_claims.message_type == "LtiDeepLinkingRequest" {
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?;
let dl_request_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
"INSERT INTO lti_deep_linking_requests (id, registration_id, deployment_id, return_url, data) VALUES ($1, $2, $3, $4, $5)"
)
.bind(user.id)
.bind(registration.organization_id)
.bind(link.course_id)
.bind(dl_request_id)
.bind(registration.id)
.bind(&lti_claims.deployment_id)
.bind(&settings.deep_link_return_url)
.bind(&settings.data)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
format!("/courses/{}", link.course_id)
Ok(Redirect::to(&format!("{}/lti/deep-linking?token={}&dl_token={}", studio_url, token, dl_request_id)))
} else {
"/dashboard".to_string()
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
}
}
use serde_json::json;
#[derive(Deserialize)]
pub struct LtiDeepLinkingResponsePayload {
pub dl_token: String,
pub items: Vec<common::models::LtiDeepLinkingContentItem>,
}
pub async fn lti_deep_linking_response(
State(pool): State<PgPool>,
claims: Claims,
Json(payload): Json<LtiDeepLinkingResponsePayload>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// 1. Retrieve and delete DL request
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?;
let dl_request = sqlx::query(
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
)
.bind(dl_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?;
// Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct
let registration_id: Uuid = dl_request.get("registration_id");
let deployment_id: String = dl_request.get("deployment_id");
let _return_url: String = dl_request.get::<String, _>("return_url");
let dl_data: Option<String> = dl_request.get("data");
// 2. Find registration
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE id = $1",
)
.bind(registration_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let now = chrono::Utc::now().timestamp();
let response_claims = common::models::LtiDeepLinkingResponseClaims {
issuer: registration.client_id,
subject: claims.sub.to_string(),
audience: registration.issuer,
expires_at: now + 3600,
issued_at: now,
nonce: Uuid::new_v4().to_string(),
message_type: "LtiDeepLinkingResponse".to_string(),
version: "1.3.0".to_string(),
deployment_id,
content_items: payload.items,
data: dl_data,
};
// 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()),
let private_key = crate::jwks::get_lti_private_key();
let response_jwt = jsonwebtoken::encode(
&jsonwebtoken::Header {
kid: Some("openccb-lti-key-1".to_string()),
alg: jsonwebtoken::Algorithm::RS256,
..Default::default()
},
&response_claims,
&private_key,
)
.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))))
Ok(Json(json!({
"jwt": response_jwt,
"return_url": dl_request.get::<String, _>("return_url")
})))
}
use axum::Json;
use sqlx::Row;
+18
View File
@@ -7,11 +7,16 @@ mod handlers_notes;
mod handlers_payments;
mod handlers_peer_review;
mod lti;
mod jwks;
mod predictive;
mod live;
mod portfolio;
use axum::{
Router, middleware,
routing::{delete, get, post, put},
};
use axum::Json; // Added based on instruction
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
@@ -86,6 +91,17 @@ async fn main() {
"/courses/{id}/recommendations",
get(handlers::get_recommendations),
)
.route(
"/courses/{id}/dropout-risks",
get(predictive::get_course_dropout_risks),
)
// Live Learning
.route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting))
.route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting))
// Portfolio & Badges
.route("/profile/{user_id}", get(portfolio::get_public_profile))
.route("/my/badges", get(portfolio::get_my_badges))
.route("/badges/award", post(portfolio::award_badge))
.route(
"/users/{id}/gamification",
get(handlers::get_user_gamification),
@@ -210,6 +226,8 @@ async fn main() {
)
.route("/lti/login", get(lti::lti_login_initiation))
.route("/lti/launch", post(lti::lti_launch))
.route("/lti/jwks", get(jwks::lti_jwks_handler))
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
.merge(protected_routes)
.layer(cors)
.with_state(pool);
+94
View File
@@ -0,0 +1,94 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use common::auth::Claims;
use common::models::{Badge, UserBadge, PublicProfile};
use sqlx::{PgPool, Row};
use uuid::Uuid;
pub async fn get_public_profile(
Path(user_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<PublicProfile>, (StatusCode, String)> {
let user = sqlx::query("SELECT id, full_name, avatar_url, bio, level, xp, is_public_profile FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let is_public: bool = user.get("is_public_profile");
if !is_public {
return Err((StatusCode::FORBIDDEN, "This profile is private".to_string()));
}
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
r#"
SELECT b.* FROM badges b
JOIN user_badges ub ON b.id = ub.badge_id
WHERE ub.user_id = $1
"#
)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let completed_courses: i64 = sqlx::query("SELECT COUNT(*) FROM enrollments WHERE user_id = $1 AND progress_percentage >= 100")
.bind(user_id)
.fetch_one(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.get(0);
Ok(Json(PublicProfile {
user_id,
full_name: user.get("full_name"),
avatar_url: user.get("avatar_url"),
bio: user.get("bio"),
badges,
level: user.get("level"),
xp: user.get("xp"),
completed_courses_count: completed_courses,
}))
}
pub async fn get_my_badges(
claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Badge>>, (StatusCode, String)> {
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
r#"
SELECT b.* FROM badges b
JOIN user_badges ub ON b.id = ub.badge_id
WHERE ub.user_id = $1
"#
)
.bind(claims.sub)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(badges))
}
pub async fn award_badge(
State(pool): State<PgPool>,
claims: Claims,
Json(payload): Json<UserBadge>,
) -> Result<StatusCode, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string()));
}
sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING")
.bind(payload.user_id)
.bind(payload.badge_id)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::CREATED)
}
+136
View File
@@ -0,0 +1,136 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::{Utc, Duration};
use serde_json::json;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use common::auth::Claims;
use common::models::{DropoutRisk, DropoutRiskLevel, DropoutRiskReason};
pub async fn get_course_dropout_risks(
Path(course_id): Path<Uuid>,
State(pool): State<PgPool>,
claims: Claims,
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string()));
}
calculate_risks_for_course(&pool, course_id, claims.org).await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rows = sqlx::query(
r#"
SELECT id, organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at, created_at, updated_at
FROM dropout_risks
WHERE course_id = $1 AND organization_id = $2
ORDER BY score DESC
"#,
)
.bind(course_id)
.bind(claims.org)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?;
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
DropoutRisk {
id: row.get("id"),
organization_id: row.get("organization_id"),
course_id: row.get("course_id"),
user_id: row.get("user_id"),
risk_level: row.get("risk_level"),
score: row.get("score"),
reasons: row.get("reasons"),
last_calculated_at: row.get("last_calculated_at"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}).collect();
Ok(Json(risks))
}
pub async fn calculate_risks_for_course(
pool: &PgPool,
course_id: Uuid,
organization_id: Uuid,
) -> Result<(), sqlx::Error> {
let enrollments = sqlx::query("SELECT user_id FROM enrollments WHERE course_id = $1 AND organization_id = $2")
.bind(course_id)
.bind(organization_id)
.fetch_all(pool)
.await?;
for enrollment in enrollments {
let user_id: Uuid = enrollment.get("user_id");
let avg_grade: f32 = sqlx::query("SELECT COALESCE(AVG(score), 0.0) FROM user_grades WHERE user_id = $1 AND course_id = $2")
.bind(user_id)
.bind(course_id)
.fetch_one(pool)
.await?
.get::<f64, _>(0) as f32; // AVG returns f64 usually
let last_activity_count: i64 = sqlx::query("SELECT COUNT(*) FROM lesson_interactions WHERE user_id = $1 AND created_at > $2")
.bind(user_id)
.bind(Utc::now() - Duration::days(7))
.fetch_one(pool)
.await?
.get(0);
let forum_posts: i64 = sqlx::query("SELECT COUNT(*) FROM discussion_posts WHERE author_id = $1 AND organization_id = $2")
.bind(user_id)
.bind(organization_id)
.fetch_one(pool)
.await?
.get(0);
let perf_risk = (1.0 - avg_grade).max(0.0);
let activity_risk = (1.0 / (last_activity_count as f32 + 1.0)).min(1.0);
let social_risk = (1.0 / (forum_posts as f32 + 1.0)).min(1.0);
let total_score = (perf_risk * 0.5) + (activity_risk * 0.4) + (social_risk * 0.1);
let risk_level = if total_score > 0.8 {
DropoutRiskLevel::Critical
} else if total_score > 0.5 {
DropoutRiskLevel::High
} else if total_score > 0.3 {
DropoutRiskLevel::Medium
} else {
DropoutRiskLevel::Low
};
let reasons = vec![
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) },
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) },
];
sqlx::query(
r#"
INSERT INTO dropout_risks (organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (course_id, user_id) DO UPDATE SET
risk_level = EXCLUDED.risk_level,
score = EXCLUDED.score,
reasons = EXCLUDED.reasons,
last_calculated_at = EXCLUDED.last_calculated_at,
updated_at = NOW()
"#,
)
.bind(organization_id)
.bind(course_id)
.bind(user_id)
.bind(risk_level)
.bind(total_score)
.bind(json!(reasons))
.execute(pool)
.await?;
}
Ok(())
}