feat: Add i18n support, new content block types, course export, and lesson interaction tracking.
This commit is contained in:
@@ -24,3 +24,4 @@ hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
openidconnect.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sqlx::{Postgres, PgConnection};
|
||||
use sqlx::PgConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn set_session_context(
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Create lesson_interactions table for engagement tracking
|
||||
CREATE TABLE IF NOT EXISTS lesson_interactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
lesson_id UUID NOT NULL,
|
||||
video_timestamp FLOAT, -- Timestamp in seconds for video content
|
||||
event_type VARCHAR(50) NOT NULL, -- 'heartbeat', 'pause', 'seek', 'complete', 'start'
|
||||
metadata JSONB, -- Additional data (segment duration, playback speed, etc.)
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for efficient querying of heatmaps
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_lesson_id ON lesson_interactions(lesson_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_user_id ON lesson_interactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_org_id ON lesson_interactions(organization_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Create notifications table for in-app alerts
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
notification_type VARCHAR(50) DEFAULT 'info', -- 'info', 'warning', 'success', 'deadline'
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
link_url VARCHAR(255), -- Optional link to redirect the user
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_org_id ON notifications(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id) WHERE is_read = FALSE;
|
||||
@@ -7,8 +7,8 @@ use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use common::auth::{Claims, create_jwt};
|
||||
use common::middleware::Org;
|
||||
use common::models::{
|
||||
AuthResponse, Course, CourseAnalytics, Enrollment, Lesson, LessonAnalytics, Module,
|
||||
Organization, User, UserResponse,
|
||||
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
||||
Module, Notification, Organization, User, UserResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{PgPool, Row};
|
||||
@@ -104,6 +104,13 @@ pub struct GradeSubmissionPayload {
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InteractionPayload {
|
||||
pub video_timestamp: Option<f64>,
|
||||
pub event_type: String, // 'heartbeat', 'pause', 'seek', 'complete', 'start'
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AuthPayload>,
|
||||
@@ -442,7 +449,7 @@ pub async fn ingest_course(
|
||||
}
|
||||
|
||||
pub async fn get_course_outline(
|
||||
Org(org_ctx): Org,
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
||||
@@ -503,7 +510,7 @@ pub async fn get_course_outline(
|
||||
}
|
||||
|
||||
pub async fn get_lesson_content(
|
||||
Org(org_ctx): Org,
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
@@ -518,7 +525,7 @@ pub async fn get_lesson_content(
|
||||
}
|
||||
|
||||
pub async fn get_user_enrollments(
|
||||
Org(org_ctx): Org,
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
||||
@@ -748,7 +755,7 @@ pub async fn get_leaderboard(
|
||||
}
|
||||
|
||||
pub async fn get_user_course_grades(
|
||||
Org(org_ctx): Org,
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
||||
@@ -865,6 +872,129 @@ pub async fn get_advanced_analytics(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn record_interaction(
|
||||
Org(org_ctx): Org,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<InteractionPayload>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
sqlx::query(
|
||||
"INSERT INTO lesson_interactions (organization_id, user_id, lesson_id, video_timestamp, event_type, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.bind(payload.video_timestamp)
|
||||
.bind(payload.event_type)
|
||||
.bind(payload.metadata)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to record interaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn get_lesson_heatmap(
|
||||
Org(org_ctx): Org,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<HeatmapPoint>>, StatusCode> {
|
||||
let heatmap = sqlx::query_as::<_, HeatmapPoint>(
|
||||
"SELECT floor(video_timestamp)::int as second, count(*)::bigint as count
|
||||
FROM lesson_interactions
|
||||
WHERE lesson_id = $1 AND organization_id = $2 AND video_timestamp IS NOT NULL
|
||||
GROUP BY second
|
||||
ORDER BY second",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to fetch heatmap: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(heatmap))
|
||||
}
|
||||
|
||||
pub async fn get_notifications(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Notification>>, StatusCode> {
|
||||
let notifications = sqlx::query_as::<_, Notification>(
|
||||
"SELECT * FROM notifications WHERE user_id = $1 AND organization_id = $2 ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to fetch notifications: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(notifications))
|
||||
}
|
||||
|
||||
pub async fn mark_notification_as_read(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
sqlx::query(
|
||||
"UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 AND organization_id = $3"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to mark notification as read: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn check_deadlines_and_notify(pool: PgPool) {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||
SELECT
|
||||
l.organization_id,
|
||||
e.user_id,
|
||||
'Fecha límite próxima: ' || l.title,
|
||||
'La lección \"' || l.title || '\" del curso \"' || c.title || '\" vence en menos de 24 horas.',
|
||||
'deadline',
|
||||
'/courses/' || c.id || '/lessons/' || l.id
|
||||
FROM enrollments e
|
||||
JOIN lessons l ON l.course_id = e.course_id
|
||||
JOIN courses c ON c.id = l.course_id
|
||||
WHERE l.due_date BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notifications n
|
||||
WHERE n.user_id = e.user_id
|
||||
AND n.notification_type = 'deadline'
|
||||
AND n.link_url = '/courses/' || c.id || '/lessons/' || l.id
|
||||
AND n.created_at > NOW() - INTERVAL '48 hours'
|
||||
)"
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Failed to run deadline notifications: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
|
||||
@@ -29,6 +29,15 @@ async fn main() {
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
// Start background task for deadline notifications
|
||||
let pool_clone = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
handlers::check_deadlines_and_notify(pool_clone.clone()).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour
|
||||
}
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
@@ -58,6 +67,16 @@ async fn main() {
|
||||
)
|
||||
.route("/users/{id}", post(handlers::update_user))
|
||||
.route("/analytics/leaderboard", get(handlers::get_leaderboard))
|
||||
.route(
|
||||
"/lessons/{id}/interactions",
|
||||
post(handlers::record_interaction),
|
||||
)
|
||||
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||
.route("/notifications", get(handlers::get_notifications))
|
||||
.route(
|
||||
"/notifications/{id}/read",
|
||||
post(handlers::mark_notification_as_read),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user