From 7de24469a39a7454010133e7daba9cebc2da8aa7 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 27 Apr 2026 14:22:09 -0400 Subject: [PATCH] feat: implement AGS support with OAuth2 token management and score passback functionality --- .../migrations/20260427000006_lti_ags.sql | 16 ++ .../migrations/20260427000006_lti_ags.sql | 18 ++ .../lms-service/src/handlers_lti_consumer.rs | 223 ++++++++++++++++++ .../lms-service/src/handlers_study_rooms.rs | 98 ++++++++ services/lms-service/src/main.rs | 8 + 5 files changed, 363 insertions(+) create mode 100644 services/cms-service/migrations/20260427000006_lti_ags.sql create mode 100644 services/lms-service/migrations/20260427000006_lti_ags.sql diff --git a/services/cms-service/migrations/20260427000006_lti_ags.sql b/services/cms-service/migrations/20260427000006_lti_ags.sql new file mode 100644 index 0000000..bf4a13d --- /dev/null +++ b/services/cms-service/migrations/20260427000006_lti_ags.sql @@ -0,0 +1,16 @@ +-- Fase 39: Campos AGS en lti_external_tools (cms-service mirror) +ALTER TABLE lti_external_tools + ADD COLUMN IF NOT EXISTS ags_client_id TEXT, + ADD COLUMN IF NOT EXISTS ags_client_secret TEXT, + ADD COLUMN IF NOT EXISTS ags_token_url TEXT, + ADD COLUMN IF NOT EXISTS ags_lineitem_url TEXT; + +CREATE TABLE IF NOT EXISTS lti_ags_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tool_id UUID NOT NULL REFERENCES lti_external_tools(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lti_ags_tokens_tool ON lti_ags_tokens(tool_id); diff --git a/services/lms-service/migrations/20260427000006_lti_ags.sql b/services/lms-service/migrations/20260427000006_lti_ags.sql new file mode 100644 index 0000000..729a7af --- /dev/null +++ b/services/lms-service/migrations/20260427000006_lti_ags.sql @@ -0,0 +1,18 @@ +-- Fase 39: Campos AGS (OAuth2 Assignment and Grade Services) en lti_external_tools +-- Modo dual: HMAC legacy + AGS nuevo (ambos soportados simultáneamente) +ALTER TABLE lti_external_tools + ADD COLUMN IF NOT EXISTS ags_client_id TEXT, + ADD COLUMN IF NOT EXISTS ags_client_secret TEXT, + ADD COLUMN IF NOT EXISTS ags_token_url TEXT, + ADD COLUMN IF NOT EXISTS ags_lineitem_url TEXT; + +-- Cache de access tokens OAuth2 AGS para evitar llamadas repetidas +CREATE TABLE IF NOT EXISTS lti_ags_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tool_id UUID NOT NULL REFERENCES lti_external_tools(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lti_ags_tokens_tool ON lti_ags_tokens(tool_id); diff --git a/services/lms-service/src/handlers_lti_consumer.rs b/services/lms-service/src/handlers_lti_consumer.rs index 1a09712..2332886 100644 --- a/services/lms-service/src/handlers_lti_consumer.rs +++ b/services/lms-service/src/handlers_lti_consumer.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, }; +use base64::Engine; use common::{ auth::Claims, middleware::Org, @@ -493,3 +494,225 @@ pub async fn rotate_lti_tool_secret( rotated_at: now, })) } + +// ─── LTI AGS: OAuth2 Assignment and Grade Services (Fase 39) ───────────────── + +#[derive(Debug, Deserialize)] +pub struct AgsPassbackPayload { + pub user_id: Uuid, + pub lesson_id: Option, + pub score: f32, + pub max_score: Option, + pub status: Option, + pub metadata: Option, +} + +#[derive(Debug, Serialize)] +pub struct AgsPassbackResponse { + pub success: bool, + pub tool_id: Uuid, + pub user_id: Uuid, + pub normalized_score: f32, + pub method: String, +} + +/// Obtiene (o renueva desde caché) un access token OAuth2 para AGS. +async fn get_ags_token( + pool: &PgPool, + tool_id: Uuid, + client_id: &str, + client_secret: &str, + token_url: &str, +) -> Result { + use chrono::Utc; + + // Intentar token cacheado no expirado (con 60s de margen) + let cached = sqlx::query_scalar::<_, String>( + "SELECT access_token FROM lti_ags_tokens WHERE tool_id = $1 AND expires_at > NOW() + INTERVAL '60 seconds' ORDER BY created_at DESC LIMIT 1", + ) + .bind(tool_id) + .fetch_optional(pool) + .await + .unwrap_or(None); + + if let Some(token) = cached { + return Ok(token); + } + + // Solicitar nuevo token con client_credentials + let params = [ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret), + ("scope", "https://purl.imsglobal.org/spec/lti-ags/scope/score"), + ]; + + let resp = reqwest::Client::new() + .post(token_url) + .form(¶ms) + .send() + .await + .map_err(|e| format!("AGS token request failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("AGS token server returned {}", resp.status())); + } + + #[derive(serde::Deserialize)] + struct TokenResp { + access_token: String, + expires_in: Option, + } + + let token_resp: TokenResp = resp + .json() + .await + .map_err(|e| format!("AGS token parse failed: {}", e))?; + + let expires_secs = token_resp.expires_in.unwrap_or(3600) as i64; + let expires_at = Utc::now() + chrono::Duration::seconds(expires_secs); + + // Guardar en caché (ignorar error de BD — el token sigue siendo válido) + let _ = sqlx::query( + "INSERT INTO lti_ags_tokens (tool_id, access_token, expires_at) VALUES ($1, $2, $3)", + ) + .bind(tool_id) + .bind(&token_resp.access_token) + .bind(expires_at) + .execute(pool) + .await; + + Ok(token_resp.access_token) +} + +/// Passback AGS: POST score a lineitem_url con token OAuth2 Bearer. +/// Acepta: Authorization: Bearer : +/// La herramienta debe tener configurados: ags_client_id, ags_client_secret, ags_token_url, ags_lineitem_url. +pub async fn lti_ags_score_passback( + State(pool): State, + headers: HeaderMap, + Path(tool_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Leer credenciales AGS del header Authorization (Basic base64(client_id:client_secret)) + let auth_header = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let (req_client_id, req_client_secret) = if let Some(encoded) = auth_header.strip_prefix("Basic ") { + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Authorization inválido".to_string()))?; + let s = String::from_utf8(decoded) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Authorization inválido".to_string()))?; + let mut parts = s.splitn(2, ':'); + let id = parts.next().unwrap_or("").to_string(); + let secret = parts.next().unwrap_or("").to_string(); + (id, secret) + } else { + return Err((StatusCode::UNAUTHORIZED, "Se requiere Authorization: Basic :".to_string())); + }; + + // Obtener config AGS de la herramienta + #[derive(sqlx::FromRow)] + struct AgsConfig { + ags_client_id: Option, + ags_client_secret: Option, + ags_token_url: Option, + ags_lineitem_url: Option, + organization_id: Uuid, + course_id: Uuid, + } + + let config = sqlx::query_as::<_, AgsConfig>( + "SELECT ags_client_id, ags_client_secret, ags_token_url, ags_lineitem_url, organization_id, course_id FROM lti_external_tools WHERE id = $1", + ) + .bind(tool_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string()))?; + + let client_id = config.ags_client_id.as_deref().unwrap_or(""); + let client_secret = config.ags_client_secret.as_deref().unwrap_or(""); + let token_url = config.ags_token_url.as_deref().unwrap_or(""); + let lineitem_url = config.ags_lineitem_url.as_deref().unwrap_or(""); + + if client_id.is_empty() || token_url.is_empty() || lineitem_url.is_empty() { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "La herramienta no tiene AGS configurado (ags_client_id, ags_token_url, ags_lineitem_url son requeridos)".to_string(), + )); + } + + // Validar credenciales del request contra la BD + if req_client_id != client_id || req_client_secret != client_secret { + return Err((StatusCode::UNAUTHORIZED, "Credenciales AGS inválidas".to_string())); + } + + // Obtener token OAuth2 + let access_token = get_ags_token(&pool, tool_id, client_id, client_secret, token_url) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + + let max_score = payload.max_score.unwrap_or(1.0); + let normalized = if max_score > 0.0 { payload.score / max_score } else { 0.0 }; + let normalized = normalized.clamp(0.0, 1.0); + + // Payload IMS AGS Score + let score_url = format!("{}/scores", lineitem_url.trim_end_matches('/')); + let score_body = serde_json::json!({ + "userId": payload.user_id.to_string(), + "scoreGiven": payload.score, + "scoreMaximum": max_score, + "activityProgress": "Completed", + "gradingProgress": "FullyGraded", + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + + let ags_resp = reqwest::Client::new() + .post(&score_url) + .bearer_auth(&access_token) + .header("Content-Type", "application/vnd.ims.lis.v1.score+json") + .json(&score_body) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("AGS score POST falló: {}", e)))?; + + if !ags_resp.status().is_success() { + let status = ags_resp.status(); + let body = ags_resp.text().await.unwrap_or_default(); + return Err((StatusCode::BAD_GATEWAY, format!("AGS server returned {}: {}", status, body))); + } + + // Sincronizar a user_grades localmente si hay lesson_id + if let Some(lesson_id) = payload.lesson_id { + let _ = sqlx::query( + r#" + INSERT INTO user_grades (user_id, course_id, lesson_id, score, max_score, normalized_score, passed, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (user_id, lesson_id) DO UPDATE + SET score = EXCLUDED.score, max_score = EXCLUDED.max_score, + normalized_score = EXCLUDED.normalized_score, passed = EXCLUDED.passed, updated_at = NOW() + "#, + ) + .bind(payload.user_id) + .bind(config.course_id) + .bind(lesson_id) + .bind(payload.score) + .bind(max_score) + .bind(normalized) + .bind(normalized >= 0.6) + .execute(&pool) + .await; + } + + Ok(Json(AgsPassbackResponse { + success: true, + tool_id, + user_id: payload.user_id, + normalized_score: normalized, + method: "ags_oauth2".to_string(), + })) +} diff --git a/services/lms-service/src/handlers_study_rooms.rs b/services/lms-service/src/handlers_study_rooms.rs index a6ab8fc..6b12054 100644 --- a/services/lms-service/src/handlers_study_rooms.rs +++ b/services/lms-service/src/handlers_study_rooms.rs @@ -388,3 +388,101 @@ pub async fn delete_study_room( Ok(StatusCode::NO_CONTENT) } + +// ─── Grabaciones BBB (Fase 39) ─────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct BbbRecording { + pub record_id: String, + pub meeting_id: String, + pub name: String, + pub state: String, + pub start_time: i64, + pub end_time: i64, + pub participants: i64, + pub playback_url: Option, + pub duration_minutes: i64, +} + +pub async fn get_study_room_recordings( + Org(org_ctx): Org, + State(pool): State, + Path((course_id, room_id)): Path<(Uuid, Uuid)>, +) -> Result>, (StatusCode, String)> { + let bbb_meeting_id = sqlx::query_scalar::<_, Option>( + "SELECT bbb_meeting_id FROM study_rooms WHERE id = $1 AND course_id = $2 AND organization_id = $3", + ) + .bind(room_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .flatten() + .ok_or((StatusCode::NOT_FOUND, "Sala no encontrada o sin ID BBB".to_string()))?; + + let params = format!("meetingID={}", urlencoding::encode(&bbb_meeting_id)); + let url = bbb_url("getRecordings", ¶ms); + + let xml_body = reqwest::Client::new() + .get(&url) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("BBB getRecordings falló: {}", e)))? + .text() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + + let recordings = parse_bbb_recordings(&xml_body); + Ok(Json(recordings)) +} + +/// Parsea el XML de BBB getRecordings extrayendo los campos relevantes. +fn parse_bbb_recordings(xml: &str) -> Vec { + let mut recordings = Vec::new(); + + for recording_block in xml.split("").skip(1) { + let get = |tag: &str| -> String { + recording_block + .split(&format!("<{}>", tag)) + .nth(1) + .and_then(|s| s.split(&format!("", tag)).next()) + .unwrap_or("") + .trim() + .to_string() + }; + + let get_i64 = |tag: &str| -> i64 { + get(tag).parse::().unwrap_or(0) + }; + + let start_time = get_i64("startTime"); + let end_time = get_i64("endTime"); + let duration_minutes = if end_time > start_time { + (end_time - start_time) / 60_000 + } else { + 0 + }; + + // Buscar URL de reproducción presentación/video + let playback_url = recording_block + .split("") + .nth(1) + .and_then(|s| s.split("").next()) + .map(|s| s.trim().to_string()); + + recordings.push(BbbRecording { + record_id: get("recordID"), + meeting_id: get("meetingID"), + name: get("name"), + state: get("state"), + start_time, + end_time, + participants: get_i64("participants"), + playback_url, + duration_minutes, + }); + } + + recordings +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 2b4cab2..e3c5ea7 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -237,6 +237,10 @@ async fn main() { "/courses/{id}/study-rooms/{room_id}", delete(handlers_study_rooms::delete_study_room), ) + .route( + "/courses/{id}/study-rooms/{room_id}/recordings", + get(handlers_study_rooms::get_study_room_recordings), + ) // Portafolio e insignias (Badges) .route("/profile/{user_id}", get(portfolio::get_public_profile)) .route("/my/badges", get(portfolio::get_my_badges)) @@ -481,6 +485,10 @@ async fn main() { "/lti/tools/{tool_id}/grade-passback", post(handlers_lti_consumer::lti_grade_passback), ) + .route( + "/lti/tools/{tool_id}/ags-score", + post(handlers_lti_consumer::lti_ags_score_passback), + ) .route("/lti/jwks", get(jwks::lti_jwks_handler)) .route("/lti/deep-linking/response", post(lti::lti_deep_linking_response)) .merge(protected_routes)