mod db_util; pub mod exporter; mod external_handlers; mod handlers; mod handlers_branding; mod handlers_assets; mod handlers_dependencies; mod handlers_library; mod handlers_rubrics; mod handlers_test_templates; mod handlers_question_bank; mod handlers_admin; mod handlers_embeddings; mod handlers_sam; mod webhooks; use axum::{ Router, extract::DefaultBodyLimit, middleware, routing::{delete, get, post, put}, }; use common::health::{self, HealthState}; use dotenvy::dotenv; use http::{Method, header}; use sqlx::postgres::PgPoolOptions; use std::env; use std::net::SocketAddr; use std::time::Duration; use tower_http::cors::{Any, CorsLayer}; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; #[tokio::main] async fn main() { dotenv().ok(); tracing_subscriber::fmt::init(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let pool = PgPoolOptions::new() .max_connections(10) .min_connections(2) .acquire_timeout(Duration::from_secs(30)) .connect(&db_url) .await .expect("Failed to connect to database"); // Initialize health state let health_state = HealthState::default(); sqlx::migrate!("./migrations") .run(&pool) .await .expect("Failed to run migrations"); // Sync default organization branding from environment sync_default_organization(&pool).await; // Start AI Background Worker let worker_pool = pool.clone(); tokio::spawn(async move { tracing::info!("AI Background Worker started"); loop { // Check for queued transcriptions let queued_lessons: Vec = match sqlx::query_scalar( "SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5", ) .fetch_all(&worker_pool) .await { Ok(ids) => ids, Err(e) => { tracing::error!("Failed to fetch queued lessons: {}", e); tokio::time::sleep(Duration::from_secs(10)).await; continue; } }; for lesson_id in queued_lessons { tracing::info!("Processing transcription for lesson: {}", lesson_id); if let Err(e) = handlers::run_transcription_task(worker_pool.clone(), lesson_id).await { tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e); let _ = sqlx::query( "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1", ) .bind(lesson_id) .execute(&worker_pool) .await; } } tokio::time::sleep(Duration::from_secs(5)).await; } }); // CORS configuration - Allow multiple origins for development and production // Using a predicate closure to support wildcard subdomains for norteamericano.cl use tower_http::cors::AllowOrigin; let cors = CorsLayer::new() .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { let origin_str = origin.to_str().unwrap_or(""); // Development origins let allowed_origins = [ "http://localhost:3000", "http://localhost:3003", "http://127.0.0.1:3000", "http://127.0.0.1:3003", "http://192.168.0.254:3000", "http://192.168.0.254:3003", "http://192.168.0.254", // Production - Norteamericano domains (HTTPS) "https://studio.norteamericano.cl", "https://learning.norteamericano.cl", ]; // Check exact matches if allowed_origins.contains(&origin_str) { return true; } // Check wildcard for subdomains: https://*.norteamericano.cl if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") { let subdomain = origin_str .strip_prefix("https://") .unwrap_or("") .strip_suffix(".norteamericano.cl") .unwrap_or(""); // Allow any subdomain (e.g., api., cdn., admin., etc.) if !subdomain.is_empty() && !subdomain.contains('/') { return true; } } false })) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD]) .allow_headers([ header::CONTENT_TYPE, header::AUTHORIZATION, header::HeaderName::from_static("x-requested-with"), header::HeaderName::from_static("x-organization-id"), header::RANGE, ]) .expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE, header::CONTENT_RANGE, header::ACCEPT_RANGES]); // Rate limiting: Deshabilitado temporalmente por problemas de compatibilidad con tower-governor // Para habilitar en producción, configurar con GovernorLayer y ajustar los límites apropiadamente // Rutas protegidas que requieren autenticación y contexto de organización let protected_routes = Router::new() .route( "/courses", get(handlers::get_courses).post(handlers::create_course), ) .route( "/courses/{id}", get(handlers::get_course) .put(handlers::update_course) .delete(handlers::delete_course), ) .route("/courses/{id}/publish", post(handlers::publish_course)) .route("/courses/{id}/outline", get(handlers::get_course_outline)) .route( "/courses/{id}/analytics", get(handlers::get_course_analytics), ) .route( "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), ) .route( "/courses/{id}/team", get(handlers::get_course_team).post(handlers::add_team_member), ) .route( "/courses/{id}/team/{user_id}", delete(handlers::remove_team_member), ) .route( "/courses/{id}/preview-token", post(handlers::create_course_preview_token), ) .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route( "/modules", get(handlers::get_modules).post(handlers::create_module), ) .route("/modules/reorder", post(handlers::reorder_modules)) .route( "/modules/{id}", axum::routing::put(handlers::update_module).delete(handlers::delete_module), ) .route( "/lessons", get(handlers::get_lessons).post(handlers::create_lesson), ) .route("/lessons/reorder", post(handlers::reorder_lessons)) .route( "/lessons/{id}", get(handlers::get_lesson) .put(handlers::update_lesson) .delete(handlers::delete_lesson), ) .route( "/lessons/{id}/transcribe", post(handlers::process_transcription), ) .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) .route("/lessons/{id}/generate-role-play", post(handlers::generate_role_play)) .route("/lessons/{id}/generate-hotspots", post(handlers::generate_hotspots)) .route("/lessons/{id}/generate-mermaid", post(handlers::generate_mermaid_diagram)) .route("/lessons/{id}/generate-code-lab", post(handlers::generate_code_lab)) .route("/courses/generate", post(handlers::generate_course)) .route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/import", post(handlers::import_course)) .route("/grading", post(handlers::create_grading_category)) .route("/grading/{id}", delete(handlers::delete_grading_category)) .route( "/courses/{id}/grading", get(handlers::get_grading_categories), ) .route("/tipo-nota", get(handlers::get_tipo_nota)) .route("/auth/me", get(handlers::get_me)) .route( "/users", get(handlers::get_all_users).post(handlers::admin_create_user), ) .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", 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", get(handlers::get_organizations).post(handlers::create_organization), ) .route("/organizations/{id}", put(handlers::update_organization)) .route("/admin/provision", post(handlers::provision_organization)) */ .route( "/webhooks", get(handlers::get_webhooks).post(handlers::create_webhook), ) .route("/webhooks/{id}", delete(handlers::delete_webhook)) .route("/tasks", get(handlers::tasks::get_background_tasks)) .route("/tasks/{id}/retry", post(handlers::tasks::retry_task)) .route("/tasks/{id}", delete(handlers::tasks::cancel_task)) .route("/organization", get(handlers::get_organization)) .route( "/organization/sso", get(handlers::get_sso_config).put(handlers::update_sso_config), ) .route( "/organization/logo", post(handlers_branding::upload_organization_logo), ) .route( "/organization/favicon", post(handlers_branding::upload_organization_favicon), ) .route( "/organization/branding", axum::routing::put(handlers_branding::update_organization_branding), ) // Content Libraries routes .route( "/library/blocks", get(handlers_library::list_library_blocks).post(handlers_library::create_library_block), ) .route( "/library/blocks/{id}", get(handlers_library::get_library_block) .put(handlers_library::update_library_block) .delete(handlers_library::delete_library_block), ) .route( "/library/blocks/{id}/increment-usage", post(handlers_library::increment_block_usage), ) // Advanced Grading (Rubrics) routes .route( "/courses/{id}/rubrics", get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric), ) .route( "/rubrics/{id}", get(handlers_rubrics::get_rubric_with_details) .put(handlers_rubrics::update_rubric) .delete(handlers_rubrics::delete_rubric), ) .route( "/rubrics/{id}/criteria", post(handlers_rubrics::create_criterion), ) .route( "/criteria/{id}", put(handlers_rubrics::update_criterion).delete(handlers_rubrics::delete_criterion), ) .route( "/criteria/{id}/levels", post(handlers_rubrics::create_level), ) .route( "/levels/{id}", put(handlers_rubrics::update_level).delete(handlers_rubrics::delete_level), ) .route( "/lessons/{lesson_id}/rubrics/{rubric_id}", post(handlers_rubrics::assign_rubric_to_lesson) .delete(handlers_rubrics::unassign_rubric_from_lesson), ) .route( "/lessons/{id}/rubrics", get(handlers_rubrics::get_lesson_rubrics), ) // Learning Sequences (Dependencies) routes .route( "/lessons/{id}/dependencies", get(handlers_dependencies::list_lesson_dependencies) .post(handlers_dependencies::assign_dependency), ) .route( "/lessons/{id}/dependencies/{prerequisite_id}", delete(handlers_dependencies::remove_dependency), ) // Test Templates routes .route( "/test-templates", get(handlers_test_templates::list_test_templates) .post(handlers_test_templates::create_test_template), ) .route( "/test-templates/{id}", get(handlers_test_templates::get_test_template) .put(handlers_test_templates::update_test_template) .delete(handlers_test_templates::delete_test_template), ) .route( "/test-templates/{id}/questions", post(handlers_test_templates::create_template_question), ) .route( "/test-templates/{template_id}/questions/{question_id}", delete(handlers_test_templates::delete_template_question), ) .route( "/test-templates/{id}/sections", post(handlers_test_templates::create_template_section), ) .route( "/test-templates/{template_id}/sections/{section_id}", delete(handlers_test_templates::delete_template_section), ) .route( "/test-templates/{id}/apply", post(handlers_test_templates::apply_template_to_lesson), ) .route( "/test-templates/generate-with-rag", post(handlers_test_templates::generate_questions_with_rag), ) // Question Bank routes .route( "/question-bank", get(handlers_question_bank::list_questions) .post(handlers_question_bank::create_question), ) .route( "/question-bank/{id}", get(handlers_question_bank::get_question) .put(handlers_question_bank::update_question) .delete(handlers_question_bank::delete_question), ) .route( "/question-bank/import-mysql", post(handlers_question_bank::import_from_mysql), ) .route( "/question-bank/mysql-plans", get(handlers_question_bank::get_mysql_plans), ) .route( "/question-bank/mysql-courses", get(handlers_question_bank::get_mysql_courses_by_plan), ) .route( "/question-bank/import-mysql-all", post(handlers_question_bank::import_all_from_mysql), ) .route( "/question-bank/import-course-mysql", post(handlers_question_bank::import_course_from_mysql), ) .route( "/question-bank/import-sam-diagnostico", post(handlers_question_bank::import_from_sam_diagnostico), ) .route( "/question-bank/ai-generate", post(handlers_question_bank::ai_generate_question), ) // Embedding routes for semantic search .route( "/question-bank/embeddings/generate", post(handlers_embeddings::generate_question_embeddings), ) .route( "/question-bank/semantic-search", get(handlers_embeddings::semantic_search), ) .route( "/question-bank/similar/{id}", get(handlers_embeddings::find_similar_questions), ) .route( "/question-bank/{id}/embedding/regenerate", post(handlers_embeddings::regenerate_question_embedding), ) // SAM Integration routes .route( "/sam/sync-all", post(handlers_sam::sync_all_sam), ) .route( "/sam/sync-students", post(handlers_sam::sync_sam_students), ) .route( "/sam/sync-assignments", post(handlers_sam::sync_sam_assignments), ) .route( "/sam/students", get(handlers_sam::list_sam_students), ) .route( "/sam/students/{student_id}/courses", get(handlers_sam::get_sam_student_courses), ) // Admin routes .route( "/admin/token-usage", get(handlers_admin::get_token_usage), ) .route( "/admin/ai-usage/global", get(handlers_admin::get_ai_usage_global), ) .route( "/admin/users/{user_id}/token-limit", put(handlers_admin::set_user_token_limit), ) .route( "/admin/users/{user_id}/token-usage", get(handlers_admin::get_user_token_usage), ) .route( "/admin/users/{user_id}/token-limit/check", get(handlers_admin::check_user_token_limit), ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); let api_routes = Router::new() .route( "/v1/courses", post(external_handlers::create_course_external), ) .route( "/v1/courses/{id}", get(external_handlers::get_course_external), ) .route( "/v1/lessons/{id}/transcribe", post(external_handlers::trigger_transcription_external), ); // Rutas públicas que no requieren autenticación let auth_routes = Router::new() .route("/auth/register", post(handlers::register)) .route("/auth/login", post(handlers::login)) .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init)) .route("/auth/sso/callback", get(handlers::sso_callback)) .route( "/branding", get(handlers_branding::get_organization_branding), ); let public_routes = Router::new() .nest("/api/external", api_routes) // Health check routes .merge(health::health_routes(pool.clone()).with_state(health_state)) .nest_service("/assets", tower_http::services::ServeDir::new("uploads")) .merge(auth_routes) .merge(protected_routes) // Security headers .layer(SetResponseHeaderLayer::overriding( http::header::STRICT_TRANSPORT_SECURITY, http::HeaderValue::from_static("max-age=31536000; includeSubDomains"), )) .layer(SetResponseHeaderLayer::overriding( http::header::X_CONTENT_TYPE_OPTIONS, http::HeaderValue::from_static("nosniff"), )) .layer(SetResponseHeaderLayer::overriding( http::header::X_FRAME_OPTIONS, http::HeaderValue::from_static("SAMEORIGIN"), )) .layer(SetResponseHeaderLayer::overriding( http::header::X_XSS_PROTECTION, http::HeaderValue::from_static("1; mode=block"), )) .layer(SetResponseHeaderLayer::overriding( http::header::REFERRER_POLICY, http::HeaderValue::from_static("strict-origin-when-cross-origin"), )) // CORS layer - MUST be last to execute first on response .layer(cors) // Trace layer for logging requests/responses .layer(TraceLayer::new_for_http()) .with_state(pool); let addr = SocketAddr::from(([0, 0, 0, 0], 3001)); tracing::info!("CMS Service listening on {} with rate limiting and security headers", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, public_routes).await.unwrap(); } async fn sync_default_organization(pool: &sqlx::PgPool) { let org_id = sqlx::types::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); let name = env::var("DEFAULT_ORG_NAME").unwrap_or_else(|_| "OpenCCB".to_string()); let platform_name = env::var("DEFAULT_PLATFORM_NAME").ok().filter(|s| !s.is_empty()); let logo_url = env::var("DEFAULT_LOGO_URL").ok().filter(|s| !s.is_empty()); let favicon_url = env::var("DEFAULT_FAVICON_URL").ok().filter(|s| !s.is_empty()); let primary_color = env::var("DEFAULT_PRIMARY_COLOR").ok().filter(|s| !s.is_empty()); let secondary_color = env::var("DEFAULT_SECONDARY_COLOR").ok().filter(|s| !s.is_empty()); let result = sqlx::query( "UPDATE organizations SET name = $1, platform_name = COALESCE($2, platform_name), logo_url = COALESCE($3, logo_url), favicon_url = COALESCE($4, favicon_url), primary_color = COALESCE($5, primary_color), secondary_color = COALESCE($6, secondary_color), updated_at = NOW() WHERE id = $7" ) .bind(name) .bind(platform_name) .bind(logo_url) .bind(favicon_url) .bind(primary_color) .bind(secondary_color) .bind(org_id) .execute(pool) .await; match result { Ok(_) => tracing::info!("Default organization branding synced from .env"), Err(e) => tracing::error!("Failed to sync default organization branding: {}", e), } }