feat: Implement AI-driven lesson summaries, automate quiz generation, add gamification base, and introduce Studio organization management.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
-- 1. Create organizations table
|
||||
CREATE TABLE organizations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add summary to lessons
|
||||
ALTER TABLE lessons ADD COLUMN summary TEXT;
|
||||
@@ -292,6 +292,79 @@ pub async fn process_transcription(
|
||||
Ok(Json(updated_lesson))
|
||||
}
|
||||
|
||||
pub async fn summarize_lesson(
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Simulate AI Summarization based on content
|
||||
// In a real scenario, this would call an LLM with the transcription or blocks content
|
||||
let mock_summary = format!(
|
||||
"This lesson, titled '{}', covers the fundamental concepts of the topic. It includes interactive elements designed to reinforce learning through practice and assessment.",
|
||||
lesson.title
|
||||
);
|
||||
|
||||
// 3. Update lesson
|
||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||
"UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(mock_summary)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
log_action(&pool, claims.sub, "SUMMARY_GENERATED", "Lesson", id, json!({})).await;
|
||||
|
||||
Ok(Json(updated_lesson))
|
||||
}
|
||||
|
||||
pub async fn generate_quiz(
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Simulate AI Quiz Generation
|
||||
// Normally would use lesson content (transcription, blocks, etc.)
|
||||
let quiz_blocks = json!([
|
||||
{
|
||||
"id": Uuid::new_v4().to_string(),
|
||||
"type": "quiz",
|
||||
"title": "Automated Content Check",
|
||||
"quiz_data": {
|
||||
"questions": [
|
||||
{
|
||||
"id": "q1",
|
||||
"type": "multiple-choice",
|
||||
"question": format!("Based on '{}', what is the primary objective?", lesson.title),
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correctAnswer": 0,
|
||||
"explanation": "This question was generated automatically based on the lesson title."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
|
||||
|
||||
Ok(Json(quiz_blocks))
|
||||
}
|
||||
|
||||
pub async fn get_lesson(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -319,15 +392,6 @@ pub async fn update_lesson(
|
||||
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
||||
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
|
||||
let grading_category_id = payload.get("grading_category_id")
|
||||
.and_then(|v| {
|
||||
if v.is_null() {
|
||||
Some(None)
|
||||
} else {
|
||||
v.as_str().and_then(|s| Uuid::parse_str(s).ok()).map(Some)
|
||||
}
|
||||
});
|
||||
|
||||
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
||||
let metadata = payload.get("metadata");
|
||||
@@ -342,8 +406,9 @@ pub async fn update_lesson(
|
||||
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END,
|
||||
metadata = COALESCE($8, metadata),
|
||||
max_attempts = COALESCE($9, max_attempts),
|
||||
allow_retry = COALESCE($10, allow_retry)
|
||||
WHERE id = $11 RETURNING *"
|
||||
allow_retry = COALESCE($10, allow_retry),
|
||||
summary = COALESCE($11, summary)
|
||||
WHERE id = $12 RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content_type)
|
||||
@@ -355,6 +420,7 @@ pub async fn update_lesson(
|
||||
.bind(metadata)
|
||||
.bind(max_attempts)
|
||||
.bind(allow_retry)
|
||||
.bind(payload.get("summary").and_then(|v| v.as_str()))
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
@@ -606,7 +672,10 @@ pub async fn register(
|
||||
.bind(&org_name)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create/find org: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e))
|
||||
})?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *"
|
||||
@@ -618,7 +687,10 @@ pub async fn register(
|
||||
.bind(organization.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create user: {}", e);
|
||||
(StatusCode::CONFLICT, format!("User already exists or DB error: {}", e))
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -739,3 +811,72 @@ pub async fn get_audit_logs(
|
||||
|
||||
Ok(Json(logs))
|
||||
}
|
||||
|
||||
pub async fn get_organization(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Organization>, StatusCode> {
|
||||
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(org))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ModuleWithLessons {
|
||||
#[serde(flatten)]
|
||||
pub module: Module,
|
||||
pub lessons: Vec<Lesson>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CourseWithOutline {
|
||||
#[serde(flatten)]
|
||||
pub course: Course,
|
||||
pub modules: Vec<ModuleWithLessons>,
|
||||
}
|
||||
|
||||
pub async fn get_course_outline(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CourseWithOutline>, StatusCode> {
|
||||
// 1. Fetch Course
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Fetch Modules
|
||||
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut modules_with_lessons = Vec::new();
|
||||
|
||||
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
|
||||
for module in modules {
|
||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||
.bind(module.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
modules_with_lessons.push(ModuleWithLessons {
|
||||
module,
|
||||
lessons,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(CourseWithOutline {
|
||||
course,
|
||||
modules: modules_with_lessons,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ModuleWithLessons {
|
||||
#[serde(flatten)]
|
||||
pub module: Module,
|
||||
pub lessons: Vec<Lesson>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CourseWithOutline {
|
||||
#[serde(flatten)]
|
||||
pub course: Course,
|
||||
pub modules: Vec<ModuleWithLessons>,
|
||||
}
|
||||
|
||||
pub async fn get_course_outline(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CourseWithOutline>, StatusCode> {
|
||||
// 1. Fetch Course
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Fetch Modules
|
||||
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut modules_with_lessons = Vec::new();
|
||||
|
||||
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
|
||||
for module in modules {
|
||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||
.bind(module.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
modules_with_lessons.push(ModuleWithLessons {
|
||||
module,
|
||||
lessons,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(CourseWithOutline {
|
||||
course,
|
||||
modules: modules_with_lessons,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
pub async fn get_organization(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Organization>, StatusCode> {
|
||||
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(org))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
mod handlers;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post, delete, put},
|
||||
routing::{get, post, delete},
|
||||
Router,
|
||||
middleware,
|
||||
};
|
||||
@@ -37,18 +37,22 @@ async fn main() {
|
||||
// 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))
|
||||
.route("/courses/{id}", get(handlers::get_course).put(handlers::update_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("/modules", get(handlers::get_modules).post(handlers::create_module))
|
||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||
.route("/lessons/:id", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
||||
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
||||
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
||||
.route("/grading", post(handlers::create_grading_category))
|
||||
.route("/grading/:id", delete(handlers::delete_grading_category))
|
||||
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
||||
.route("/courses/{id}/grading", get(handlers::get_grading_categories))
|
||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||
.route("/assets/upload", post(handlers::upload_asset))
|
||||
.route("/organization", get(handlers::get_organization))
|
||||
.route_layer(middleware::from_fn(common::middleware::org_extractor_middleware));
|
||||
|
||||
// Rutas públicas que no requieren autenticación
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration: Gamification (Points and Badges)
|
||||
-- Scoped to LMS service where student activity happens
|
||||
|
||||
-- 1. Create badges table
|
||||
CREATE TABLE badges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
icon_url VARCHAR(255),
|
||||
requirement_type VARCHAR(50) NOT NULL, -- 'points', 'course_completion', 'assessment_perfect'
|
||||
requirement_value INT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. User Badges (Many-to-Many)
|
||||
CREATE TABLE user_badges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
badge_id UUID NOT NULL,
|
||||
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, badge_id)
|
||||
);
|
||||
|
||||
-- 3. Points Log
|
||||
CREATE TABLE points_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
organization_id UUID NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
reason VARCHAR(255) NOT NULL, -- 'lesson_completion', 'assessment_pass', 'streak'
|
||||
entity_type VARCHAR(50), -- 'lesson', 'course'
|
||||
entity_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 4. Initial Badges for each organization (optional, can be done via API)
|
||||
INSERT INTO badges (organization_id, name, description, requirement_type, requirement_value)
|
||||
VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'First Steps', 'Completed your first lesson', 'points', 10),
|
||||
('00000000-0000-0000-0000-000000000001', 'Quick Learner', 'Earned 100 points', 'points', 100),
|
||||
('00000000-0000-0000-0000-000000000001', 'Course Master', 'Completed a full course', 'course_completion', 1);
|
||||
@@ -405,9 +405,91 @@ pub async fn submit_lesson_score(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Grant Points
|
||||
let points_to_grant = 20; // Base points for any lesson
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO points_log (user_id, organization_id, amount, reason, entity_type, entity_id) VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(payload.user_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(points_to_grant)
|
||||
.bind("lesson_completion")
|
||||
.bind("lesson")
|
||||
.bind(payload.lesson_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
// 5. Check for new badges (Trigger-like logic in code)
|
||||
// For now, very simple: if they reached a points threshold
|
||||
let total_points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
|
||||
.bind(payload.user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let eligible_badges = sqlx::query(
|
||||
"SELECT id FROM badges WHERE organization_id = $1 AND requirement_type = 'points' AND requirement_value <= $2 AND id NOT IN (SELECT badge_id FROM user_badges WHERE user_id = $3)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(total_points as i32)
|
||||
.bind(payload.user_id)
|
||||
.fetch_all(&pool)
|
||||
.await;
|
||||
|
||||
if let Ok(new_badges) = eligible_badges {
|
||||
for b in new_badges {
|
||||
let badge_id: Uuid = b.get("id");
|
||||
let _ = sqlx::query("INSERT INTO user_badges (user_id, badge_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
|
||||
.bind(payload.user_id)
|
||||
.bind(badge_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(grade))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GamificationStatus {
|
||||
pub points: i64,
|
||||
pub badges: Vec<BadgeResponse>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, sqlx::FromRow)]
|
||||
pub struct BadgeResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub earned_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn get_user_gamification(
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<GamificationStatus>, StatusCode> {
|
||||
let points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let badges = sqlx::query_as::<_, BadgeResponse>(
|
||||
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
|
||||
FROM user_badges ub
|
||||
JOIN badges b ON ub.badge_id = b.id
|
||||
WHERE ub.user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(GamificationStatus { points, badges }))
|
||||
}
|
||||
|
||||
pub async fn get_user_course_grades(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
mod handlers;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
middleware,
|
||||
};
|
||||
@@ -36,12 +36,13 @@ async fn main() {
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.route("/enroll", post(handlers::enroll_user))
|
||||
.route("/enrollments/:id", get(handlers::get_user_enrollments))
|
||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||
.route("/lessons/:id", get(handlers::get_lesson_content))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
||||
.route("/users/{id}/gamification", get(handlers::get_user_gamification))
|
||||
.route_layer(middleware::from_fn(common::middleware::org_extractor_middleware));
|
||||
|
||||
let public_routes = Router::new()
|
||||
|
||||
Reference in New Issue
Block a user