feat: Add i18n support, new content block types, course export, and lesson interaction tracking.

This commit is contained in:
2026-01-17 02:19:39 -03:00
parent b166387a48
commit 05faa20993
50 changed files with 3368 additions and 388 deletions
+1
View File
@@ -24,3 +24,4 @@ hmac.workspace = true
sha2.workspace = true
hex.workspace = true
openidconnect.workspace = true
anyhow.workspace = true
+1 -1
View File
@@ -1,4 +1,4 @@
use sqlx::{Postgres, PgConnection};
use sqlx::PgConnection;
use uuid::Uuid;
pub async fn set_session_context(
+65
View File
@@ -0,0 +1,65 @@
use chrono::{DateTime, Utc};
use common::models::{Course, Lesson, Module};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct CourseExport {
pub course: Course,
pub modules: Vec<ModuleWithLessons>,
pub grading_categories: Vec<common::models::GradingCategory>,
pub export_version: String,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ModuleWithLessons {
pub module: Module,
pub lessons: Vec<Lesson>,
}
pub async fn get_course_data(pool: &PgPool, course_id: Uuid) -> anyhow::Result<CourseExport> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(course_id)
.fetch_one(pool)
.await?;
// 2. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
"SELECT * FROM grading_categories WHERE course_id = $1",
)
.bind(course_id)
.fetch_all(pool)
.await?;
// 3. Fetch Modules
let modules_raw =
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(course_id)
.fetch_all(pool)
.await?;
let mut modules = Vec::new();
// 4. Fetch Lessons for each module
for module in modules_raw {
let lessons = sqlx::query_as::<_, Lesson>(
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
)
.bind(module.id)
.fetch_all(pool)
.await?;
modules.push(ModuleWithLessons { module, lessons });
}
Ok(CourseExport {
course,
modules,
grading_categories,
export_version: "1.0".to_string(),
exported_at: Utc::now(),
})
}
+385
View File
@@ -1,3 +1,4 @@
use crate::exporter;
use crate::webhooks::WebhookService;
pub mod tasks;
use axum::{
@@ -1819,6 +1820,37 @@ pub async fn get_advanced_analytics(
Ok(Json(analytics))
}
pub async fn get_lesson_heatmap(
Org(org_ctx): Org,
_claims: common::auth::Claims,
State(_pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<Vec<common::models::HeatmapPoint>>, (StatusCode, String)> {
let client = reqwest::Client::new();
let lms_url =
env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string());
let res = client
.get(format!("{}/lessons/{}/heatmap", lms_url, lesson_id))
.header("X-Organization-Id", org_ctx.id.to_string())
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
if !res.status().is_success() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch heatmap from LMS".into(),
));
}
let heatmap = res
.json::<Vec<common::models::HeatmapPoint>>()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(heatmap))
}
#[derive(Deserialize)]
pub struct AuditQuery {
pub page: Option<i64>,
@@ -2666,3 +2698,356 @@ pub async fn delete_webhook(
Ok(StatusCode::OK)
}
// --- Course Portability ---
pub async fn export_course(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<exporter::CourseExport>, StatusCode> {
// 1. Verify access (ensure course belongs to org)
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)",
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !exists {
return Err(StatusCode::NOT_FOUND);
}
// 2. Export recursively
let export = exporter::get_course_data(&pool, id).await.map_err(|e| {
tracing::error!("Export failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(export))
}
pub async fn import_course(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<exporter::CourseExport>,
) -> Result<Json<Course>, StatusCode> {
let mut tx = pool
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Create Course
let new_course = sqlx::query_as::<_, Course>(
"INSERT INTO courses (
organization_id, instructor_id, title, pacing_mode, description,
passing_percentage, certificate_template, start_date, end_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *",
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(format!("{} (Importado)", payload.course.title))
.bind(payload.course.pacing_mode)
.bind(payload.course.description)
.bind(payload.course.passing_percentage)
.bind(payload.course.certificate_template)
.bind(payload.course.start_date)
.bind(payload.course.end_date)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to create imported course: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 2. Import Grading Categories and create mapping
let mut cat_map = std::collections::HashMap::new();
for old_cat in payload.grading_categories {
let new_cat = sqlx::query_as::<_, common::models::GradingCategory>(
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
VALUES ($1, $2, $3, $4, $5)
RETURNING *",
)
.bind(org_ctx.id)
.bind(new_course.id)
.bind(old_cat.name)
.bind(old_cat.weight)
.bind(old_cat.drop_count)
.fetch_one(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
cat_map.insert(old_cat.id, new_cat.id);
}
// 3. Import Modules & Lessons
for module_data in payload.modules {
let new_module = sqlx::query_as::<_, Module>(
"INSERT INTO modules (course_id, organization_id, title, position)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(new_course.id)
.bind(org_ctx.id)
.bind(module_data.module.title)
.bind(module_data.module.position)
.fetch_one(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for lesson in module_data.lessons {
let new_cat_id = lesson
.grading_category_id
.and_then(|id| cat_map.get(&id))
.cloned();
sqlx::query(
"INSERT INTO lessons (
module_id, course_id, organization_id, title, content_type,
content_url, position, is_graded, metadata, summary,
transcription, grading_category_id, max_attempts
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
)
.bind(new_module.id)
.bind(new_course.id)
.bind(org_ctx.id)
.bind(lesson.title)
.bind(lesson.content_type)
.bind(lesson.content_url)
.bind(lesson.position)
.bind(lesson.is_graded)
.bind(lesson.metadata)
.bind(lesson.summary)
.bind(lesson.transcription)
.bind(new_cat_id)
.bind(lesson.max_attempts)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to import lesson: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
}
}
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_action(
&pool,
org_ctx.id,
claims.sub,
"COURSE_IMPORTED",
"Course",
new_course.id,
serde_json::json!({ "original_title": payload.course.title }),
)
.await;
Ok(Json(new_course))
}
// --- AI Course Generation ---
#[derive(Deserialize)]
pub struct GenerateCoursePayload {
pub prompt: String,
pub target_organization_id: Option<Uuid>,
}
pub async fn generate_course(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<GenerateCoursePayload>,
) -> Result<Json<Course>, StatusCode> {
tracing::info!(
"Starting AI course generation for prompt: {}",
payload.prompt
);
// 1. Determine target org
let target_org_id = payload.target_organization_id.unwrap_or(org_ctx.id);
// 2. AI Setup
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", api_key),
"gpt-4o".to_string(),
)
};
let system_prompt = r#"You are an expert curriculum designer.
Design a structured course based on the topic provided.
If the topic is for children or youth, use interactive content types:
- 'hotspot': Identifying image parts.
- 'memory-match': Card matching game.
- 'quiz': Standard questions.
Return ONLY a valid JSON object with the following structure:
{
"title": "Clear and Engaging Course Title",
"description": "Short overview and objectives",
"modules": [
{
"title": "Module Name",
"position": 1,
"lessons": [
{ "title": "Lesson Name", "position": 1, "content_type": "text|video|hotspot|memory-match|quiz" }
]
}
]
}"#;
let mut request = client.post(&url).json(&json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": format!("Create a course about: {}", payload.prompt) }
],
"response_format": { "type": "json_object" }
}));
if !auth_header.is_empty() {
request = request.header("Authorization", auth_header);
}
let response = request.send().await.map_err(|e| {
tracing::error!("LLM request failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
tracing::error!("LLM API error: {}", err_body);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let llm_data: serde_json::Value = response
.json()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut content_str = llm_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("{}")
.trim()
.to_string();
// Clean markdown code blocks if present
if content_str.starts_with("```") {
content_str = content_str
.lines()
.filter(|line| !line.starts_with("```"))
.collect::<Vec<_>>()
.join("\n");
}
let result_json: serde_json::Value = serde_json::from_str(&content_str).map_err(|e| {
tracing::error!("Failed to parse AI JSON: {}. Content: {}", e, content_str);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 3. Database Transaction
let mut tx = pool
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Create Course
let course_title = result_json["title"].as_str().unwrap_or("Untitled Course");
let course_desc = result_json["description"].as_str();
let course = sqlx::query_as::<_, Course>(
"INSERT INTO courses (organization_id, instructor_id, title, description, pacing_mode)
VALUES ($1, $2, $3, $4, 'self_paced')
RETURNING *",
)
.bind(target_org_id)
.bind(claims.sub)
.bind(course_title)
.bind(course_desc)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("DB Course creation failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Create Modules and Lessons
if let Some(modules) = result_json["modules"].as_array() {
for (m_idx, m_val) in modules.iter().enumerate() {
let m_title = m_val["title"].as_str().unwrap_or("Module");
let module = sqlx::query_as::<_, Module>(
"INSERT INTO modules (course_id, organization_id, title, position)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(course.id)
.bind(target_org_id)
.bind(m_title)
.bind((m_idx + 1) as i32)
.fetch_one(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Some(lessons) = m_val["lessons"].as_array() {
for (l_idx, l_val) in lessons.iter().enumerate() {
let l_title = l_val["title"].as_str().unwrap_or("Lesson");
let l_type = l_val["content_type"].as_str().unwrap_or("text");
sqlx::query(
"INSERT INTO lessons (module_id, course_id, organization_id, title, content_type, position)
VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(module.id)
.bind(course.id)
.bind(target_org_id)
.bind(l_title)
.bind(l_type)
.bind((l_idx + 1) as i32)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
}
}
}
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_action(
&pool,
target_org_id,
claims.sub,
"AI_COURSE_GENERATED",
"Course",
course.id,
json!({ "prompt": payload.prompt }),
)
.await;
Ok(Json(course))
}
+24 -14
View File
@@ -1,12 +1,12 @@
use crate::handlers::run_transcription_task;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, FromRow};
use serde::Serialize;
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::handlers::run_transcription_task;
#[derive(Debug, Serialize, FromRow)]
pub struct BackgroundTask {
@@ -24,10 +24,10 @@ pub async fn get_background_tasks(
// For now, assuming super-admin visibility or scoped by org_id in headers (which middleware handles)
// But since this is a new "Admin" feature, let's keep it simple and list all tasks for the current org context
// Ideally we should extract OrgId from request extensions, but let's query all active tasks for now.
// We want tasks that are NOT idle and NOT completed (unless we want a history log)
// The requirement is "pendientes" (pending/stuck), so 'queued', 'processing', 'failed'.
let query = r#"
SELECT
l.id,
@@ -44,7 +44,12 @@ pub async fn get_background_tasks(
let tasks = sqlx::query_as::<_, BackgroundTask>(query)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch tasks: {}", e)))?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch tasks: {}", e),
)
})?;
Ok(Json(tasks))
}
@@ -55,7 +60,7 @@ pub async fn retry_task(
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Reset status to 'queued' or directly spawn
// It's safer to spawn essentially identical logic to the upload handler
// First verify it exists
let exists = sqlx::query("SELECT 1 FROM lessons WHERE id = $1")
.bind(id)
@@ -70,7 +75,7 @@ pub async fn retry_task(
// Spawn the task
let pool_clone = pool.clone();
tokio::spawn(async move {
// Reset to queued first to indicate we are trying again?
// Reset to queued first to indicate we are trying again?
// Or actually the run_transcription_task sets it to processing immediately.
// Let's explicitly set to queued just in case, though the task runs fast.
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
@@ -79,9 +84,9 @@ pub async fn retry_task(
.await;
if let Err(e) = run_transcription_task(pool_clone, id).await {
tracing::error!("Retry transcription task failed for lesson {}: {}", id, e);
// Verify we mark it as failed is handled inside run_transcription_task?
// Let's double check that later.
tracing::error!("Retry transcription task failed for lesson {}: {}", id, e);
// Verify we mark it as failed is handled inside run_transcription_task?
// Let's double check that later.
}
});
@@ -95,12 +100,17 @@ pub async fn cancel_task(
// "Cancel" in this context mainly means setting it to 'idle' or 'failed' so it stops showing up as stuck.
// We can't easily kill a running tokio task unless we had a handle map, which we don't.
// So this is effectively "Dismiss".
sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to cancel task: {}", e)))?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to cancel task: {}", e),
)
})?;
Ok(StatusCode::NO_CONTENT)
}
+5
View File
@@ -1,4 +1,5 @@
mod db_util;
pub mod exporter;
mod handlers;
mod handlers_branding;
mod webhooks;
@@ -97,6 +98,7 @@ async fn main() {
"/courses/{id}/analytics/advanced",
get(handlers::get_advanced_analytics),
)
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
.route(
"/modules",
get(handlers::get_modules).post(handlers::create_module),
@@ -124,6 +126,9 @@ async fn main() {
.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("/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(