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, State(pool): State, claims: Claims, ) -> Result>, (StatusCode, String)> { if claims.role == "student" { return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden ver informes de riesgo".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!("Error al obtener los riesgos: {}", e)))?; let risks: Vec = 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::(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!("Calificación: {:.0}%", avg_grade * 100.0) }, DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} acciones en la última semana", 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(()) }