feat: Implement core monetization features including course pricing, payment preference creation, and transaction management with Mercado Pago integration.

This commit is contained in:
2026-02-15 13:40:48 -03:00
parent f613f34a96
commit 34e72ae985
13 changed files with 895 additions and 194 deletions
+23 -3
View File
@@ -192,11 +192,31 @@
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
## Fase 18: Monetización y Estandarización (Nuevas Sugerencias)
- [ ] **E-Commerce & Monetización**:
- [ ] Integración con Mercado Pago y Stripe.
- [ ] Sistema de precios por curso y organización.
- [ ] Dashboard de transacciones y conciliación.
- [ ] **Interoperabilidad**:
- [ ] Implementación de LTI 1.3 (Tool Provider).
- [ ] Conectividad con LMS externos (Moodle/Canvas).
- [ ] **Analíticas Predictivas**:
- [ ] Motor de IA para detección de riesgo de abandono.
- [ ] Notificaciones proactivas para instructores.
- [ ] **Gestión de Activos**:
- [ ] Biblioteca de medios global (Global Asset Manager).
- [ ] Reutilización de recursos multi-curso.
- [ ] **Aprendizaje en Vivo**:
- [ ] Integración con BigBlueButton/Jitsi.
- [ ] **Portafolio del Estudiante**:
- [ ] Perfil profesional público con Open Badges.
---
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional** y **gestión de anuncios del curso con notificaciones automáticas**.
**Próximas Prioridades**:
1. **Course Wiki**: Espacio colaborativo para documentación de cursos.
2. **Student Notes**: Anotaciones personales exportables.
3. **Course Teams**: Permisos granulares para múltiples instructores.
1. **Monetización Core**: Preparación de infraestructura para pagos y precios.
2. **LTI 1.3 Research**: Bases para la interoperabilidad.
3. **Course Wiki**: Espacio colaborativo para documentación de cursos.
4. **Student Notes**: Anotaciones personales exportables.
@@ -0,0 +1,64 @@
-- Add price and currency to courses table
ALTER TABLE courses ADD COLUMN price NUMERIC(10, 2) DEFAULT 0.00;
ALTER TABLE courses ADD COLUMN currency VARCHAR(10) DEFAULT 'USD';
-- Update fn_create_course to handle price and currency
CREATE OR REPLACE FUNCTION fn_create_course(
p_organization_id UUID,
p_instructor_id UUID,
p_title TEXT,
p_pacing_mode TEXT DEFAULT 'self_paced',
p_price NUMERIC(10, 2) DEFAULT 0.00,
p_currency TEXT DEFAULT 'USD'
) RETURNS SETOF courses AS $$
BEGIN
RETURN QUERY
INSERT INTO courses (
organization_id,
instructor_id,
title,
pacing_mode,
price,
currency
) VALUES (
p_organization_id,
p_instructor_id,
p_title,
p_pacing_mode,
p_price,
p_currency
) RETURNING *;
END;
$$ LANGUAGE plpgsql;
-- Update fn_update_course to handle price and currency
CREATE OR REPLACE FUNCTION fn_update_course(
p_id UUID,
p_organization_id UUID,
p_title TEXT,
p_description TEXT,
p_passing_percentage INTEGER,
p_pacing_mode TEXT,
p_start_date TIMESTAMP WITH TIME ZONE,
p_end_date TIMESTAMP WITH TIME ZONE,
p_certificate_template TEXT,
p_price NUMERIC(10, 2),
p_currency TEXT
) RETURNS SETOF courses AS $$
BEGIN
RETURN QUERY
UPDATE courses SET
title = p_title,
description = p_description,
passing_percentage = p_passing_percentage,
pacing_mode = p_pacing_mode,
start_date = p_start_date,
end_date = p_end_date,
certificate_template = p_certificate_template,
price = p_price,
currency = p_currency,
updated_at = NOW()
WHERE id = p_id AND organization_id = p_organization_id
RETURNING *;
END;
$$ LANGUAGE plpgsql;
+27 -2
View File
@@ -261,11 +261,23 @@ pub async fn create_course(
org_ctx.id
};
let course = sqlx::query_as::<_, Course>("SELECT * FROM fn_create_course($1, $2, $3, $4)")
let price = payload
.get("price")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let currency = payload
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("USD");
let course = sqlx::query_as::<_, Course>("SELECT * FROM fn_create_course($1, $2, $3, $4, $5, $6)")
.bind(target_org_id)
.bind(instructor_id)
.bind(title)
.bind(pacing_mode)
.bind(price)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
@@ -358,6 +370,17 @@ pub async fn update_course(
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
.or(existing.end_date);
let price = payload
.get("price")
.and_then(|v| v.as_f64())
.unwrap_or(existing.price);
let currency = payload
.get("currency")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or(existing.currency);
// BEGIN TRANSACTION
let mut tx = pool
.begin()
@@ -375,7 +398,7 @@ pub async fn update_course(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let course = sqlx::query_as::<_, Course>(
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
)
.bind(id)
.bind(org_ctx.id)
@@ -386,6 +409,8 @@ pub async fn update_course(
.bind(start_date)
.bind(end_date)
.bind(certificate_template)
.bind(price)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
@@ -0,0 +1,23 @@
-- Add price and currency to courses table in LMS
ALTER TABLE courses ADD COLUMN price NUMERIC(10, 2) DEFAULT 0.00;
ALTER TABLE courses ADD COLUMN currency VARCHAR(10) DEFAULT 'USD';
-- Create transactions table
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id),
user_id UUID NOT NULL REFERENCES users(id),
course_id UUID NOT NULL REFERENCES courses(id),
amount NUMERIC(10, 2) NOT NULL,
currency VARCHAR(10) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'success', 'failure'
provider_reference TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Trigger for updated_at in transactions
CREATE TRIGGER set_timestamp_transactions
BEFORE UPDATE ON transactions
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
+334 -128
View File
@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Path, Query, State, Multipart},
extract::{Multipart, Path, Query, State},
http::StatusCode,
};
use bcrypt::{DEFAULT_COST, hash, verify};
@@ -29,6 +29,30 @@ pub async fn enroll_user(
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
let user_id = claims.sub;
// 1. Check if course exists and get its price
let course_info: (f64, String) =
sqlx::query_as("SELECT price, currency FROM courses WHERE id = $1")
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. If it's a paid course, check for a successful transaction
if course_info.0 > 0.0 {
let has_paid: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM transactions WHERE user_id = $1 AND course_id = $2 AND status = 'success')"
)
.bind(user_id)
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !has_paid {
return Err(StatusCode::PAYMENT_REQUIRED);
}
}
let mut tx = pool
.begin()
.await
@@ -130,8 +154,12 @@ pub async fn register(
State(pool): State<PgPool>,
Json(payload): Json<AuthPayload>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let password_hash = hash(payload.password, DEFAULT_COST)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar la contraseña".into()))?;
let password_hash = hash(payload.password, DEFAULT_COST).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error al procesar la contraseña".into(),
)
})?;
let full_name = payload.full_name.unwrap_or_else(|| {
payload
@@ -262,7 +290,11 @@ pub async fn get_course_catalog(
State(pool): State<PgPool>,
Query(query): Query<CatalogQuery>,
) -> Result<Json<Vec<Course>>, StatusCode> {
tracing::info!("get_course_catalog: org_id={:?}, user_id={:?}", query.organization_id, query.user_id);
tracing::info!(
"get_course_catalog: org_id={:?}, user_id={:?}",
query.organization_id,
query.user_id
);
let courses = match (query.organization_id, query.user_id) {
(Some(org_id), Some(user_id)) => {
sqlx::query_as::<_, Course>(
@@ -346,8 +378,8 @@ pub async fn ingest_course(
// 2. Upsert Course
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
@@ -358,7 +390,9 @@ pub async fn ingest_course(
certificate_template = EXCLUDED.certificate_template,
updated_at = EXCLUDED.updated_at,
organization_id = EXCLUDED.organization_id,
pacing_mode = EXCLUDED.pacing_mode"
pacing_mode = EXCLUDED.pacing_mode,
price = EXCLUDED.price,
currency = EXCLUDED.currency"
)
.bind(payload.course.id)
.bind(&payload.course.title)
@@ -371,6 +405,8 @@ pub async fn ingest_course(
.bind(payload.course.updated_at)
.bind(org_id)
.bind(&payload.course.pacing_mode)
.bind(payload.course.price)
.bind(&payload.course.currency)
.execute(&mut *tx)
.await
.map_err(|e| {
@@ -471,7 +507,7 @@ pub async fn ingest_course(
}
// Also ingest summary as a high-relevance chunk
if let Some(summary) = &lesson.summary {
let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, summary).await;
let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, summary).await;
}
}
}
@@ -518,11 +554,18 @@ pub async fn get_course_outline(
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("get_course_outline: organization fetch failed for {}: {}", course.organization_id, e);
tracing::error!(
"get_course_outline: organization fetch failed for {}: {}",
course.organization_id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("get_course_outline: organization found: {}", organization.name);
tracing::info!(
"get_course_outline: organization found: {}",
organization.name
);
// 4. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
@@ -546,7 +589,11 @@ pub async fn get_course_outline(
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("get_course_outline: lessons fetch failed for module {}: {}", module.id, e);
tracing::error!(
"get_course_outline: lessons fetch failed for module {}: {}",
module.id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -563,17 +610,43 @@ pub async fn get_course_outline(
pub async fn get_lesson_content(
Org(_org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
tracing::info!("get_lesson_content: fetching lesson {}", id);
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
tracing::info!(
"get_lesson_content: fetching lesson {} for user {}",
id,
claims.sub
);
Ok(Json(lesson))
// Check if user is enrolled in the course this lesson belongs to
let lesson = sqlx::query_as::<_, Lesson>(
"SELECT l.* FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN enrollments e ON m.course_id = e.course_id
WHERE l.id = $1 AND e.user_id = $2",
)
.bind(id)
.bind(claims.sub)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("get_lesson_content: DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match lesson {
Some(l) => Ok(Json(l)),
None => {
tracing::warn!(
"get_lesson_content: User {} not enrolled or lesson {} not found",
claims.sub,
id
);
Err(StatusCode::FORBIDDEN)
}
}
}
pub async fn get_user_enrollments(
@@ -581,7 +654,11 @@ pub async fn get_user_enrollments(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
tracing::info!("get_user_enrollments: user_id={}, caller_org_id={}", user_id, org_ctx.id);
tracing::info!(
"get_user_enrollments: user_id={}, caller_org_id={}",
user_id,
org_ctx.id
);
let enrollments =
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
.bind(user_id)
@@ -1133,32 +1210,32 @@ pub async fn get_recommendations(
// Let's look at `20260115000009_sync_lesson_columns.sql` or similar.
// It's safer to join or use existing working query.
// I will assume the original query was correct for the schema in this environment.
let lessons =
sqlx::query_as::<_, LessonContext>("SELECT id, title, metadata FROM lessons WHERE course_id = $1")
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = sqlx::query_as::<_, LessonContext>(
"SELECT id, title, metadata FROM lessons WHERE course_id = $1",
)
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Prepare AI context with Skills Analysis
use std::collections::HashMap;
let mut skill_scores: HashMap<String, (f32, i32)> = HashMap::new(); // Tag -> (Total Score, Count)
let mut performance_summary = String::new();
for grade in &grades {
let lesson_opt = lessons.iter().find(|l| l.id == grade.lesson_id);
let lesson_title = lesson_opt
.map(|l| l.title.clone())
.unwrap_or_else(|| "Lección desconocida".to_string());
let score_percent = grade.score * 100.0;
performance_summary.push_str(&format!(
"- Lesson: {}, Score: {}%\n",
lesson_title,
score_percent as i32
lesson_title, score_percent as i32
));
// Skill Analysis
@@ -1254,12 +1331,17 @@ pub async fn evaluate_audio_response(
Json(payload): Json<AudioGradingPayload>,
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
let client = reqwest::Client::new();
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
@@ -1280,7 +1362,8 @@ pub async fn evaluate_audio_response(
payload.prompt, payload.keywords, payload.transcript
);
let response = client.post(&url)
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
@@ -1295,8 +1378,12 @@ pub async fn evaluate_audio_response(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI response parse failed: {}", e)))?;
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("AI response parse failed: {}", e),
)
})?;
let grading: AudioGradingResponse = serde_json::from_value(
ai_data["choices"][0]["message"]["content"]
@@ -1325,65 +1412,109 @@ pub async fn evaluate_audio_file(
let mut audio_data = Vec::new();
let mut filename = "audio.webm".to_string();
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"prompt" => prompt = field.text().await.unwrap_or_default(),
"keywords" => keywords_str = field.text().await.unwrap_or_default(),
"file" => {
filename = field.file_name().unwrap_or("audio.webm").to_string();
audio_data = field.bytes().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?.to_vec();
},
audio_data = field
.bytes()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_vec();
}
_ => {}
}
}
if audio_data.is_empty() {
return Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo de audio".into()));
return Err((
StatusCode::BAD_REQUEST,
"No se proporcionó ningún archivo de audio".into(),
));
}
// 1. Send to Whisper
let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
let whisper_url =
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
let client = reqwest::Client::new();
let form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(audio_data).file_name(filename))
.part(
"file",
reqwest::multipart::Part::bytes(audio_data).file_name(filename),
)
.text("model", "whisper-1")
.text("response_format", "json");
let response = client.post(format!("{}/v1/audio/transcriptions", whisper_url))
let response = client
.post(format!("{}/v1/audio/transcriptions", whisper_url))
.multipart(form)
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud a Whisper: {}", e)))?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error en la solicitud a Whisper: {}", e),
)
})?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
tracing::error!("Whisper error: {}", err_body);
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de Whisper: {}", err_body)));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error de la API de Whisper: {}", err_body),
));
}
let transcription_result: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de Whisper: {}", e)))?;
let transcription_result: serde_json::Value = response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al analizar la respuesta de Whisper: {}", e),
)
})?;
let transcript = transcription_result["text"]
.as_str()
.unwrap_or("")
.to_string();
let transcript = transcription_result["text"].as_str().unwrap_or("").to_string();
if transcript.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Whisper no pudo detectar voz. Por favor, habla más fuerte o revisa tu micrófono.".into()));
return Err((
StatusCode::BAD_REQUEST,
"Whisper no pudo detectar voz. Por favor, habla más fuerte o revisa tu micrófono."
.into(),
));
}
let keywords: Vec<String> = if keywords_str.trim().starts_with('[') {
serde_json::from_str(&keywords_str).unwrap_or_default()
} else {
keywords_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
keywords_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// 2. Perform AI Grading
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
@@ -1404,7 +1535,8 @@ pub async fn evaluate_audio_file(
prompt, keywords, transcript
);
let response = client.post(&url)
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
@@ -1417,10 +1549,19 @@ pub async fn evaluate_audio_file(
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error en la solicitud de IA: {}", e),
)
})?;
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al analizar la respuesta de la IA: {}", e),
)
})?;
let grading: AudioGradingResponse = serde_json::from_value(
ai_data["choices"][0]["message"]["content"]
@@ -1458,19 +1599,25 @@ pub async fn chat_with_tutor(
Json(payload): Json<ChatPayload>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
// 1. Fetch lesson context (summary and transcription)
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
let lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
// 1.5 Fetch previous lessons in the course for context
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
.bind(lesson.module_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al obtener el contexto del módulo".into()))?;
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error al obtener el contexto del módulo".into(),
)
})?;
let previous_lessons = sqlx::query(
r#"
@@ -1487,7 +1634,12 @@ pub async fn chat_with_tutor(
.bind(lesson.position)
.fetch_all(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?;
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch previous lessons".into(),
)
})?;
let mut history_context = String::new();
if !previous_lessons.is_empty() {
@@ -1510,7 +1662,11 @@ pub async fn chat_with_tutor(
"CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}",
lesson.title,
lesson.summary.as_deref().unwrap_or_default(),
lesson.transcription.as_ref().and_then(|t| t.get("text").and_then(|text| text.as_str())).unwrap_or("No hay transcripción disponible."),
lesson
.transcription
.as_ref()
.and_then(|t| t.get("text").and_then(|text| text.as_str()))
.unwrap_or("No hay transcripción disponible."),
block_content,
history_context
);
@@ -1536,22 +1692,25 @@ pub async fn chat_with_tutor(
tracing::error!("Failed to create chat session: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error al crear la sesión de chat".into())
})?;
use sqlx::Row;
let sid: Uuid = row.get(0);
sid
};
// Save user message
sqlx::query(
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
)
.bind(session_id)
.bind("user")
.bind(&payload.message)
.execute(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save user message".into()))?;
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
.bind(session_id)
.bind("user")
.bind(&payload.message)
.execute(&pool)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to save user message".into(),
)
})?;
// Fetch last 6 messages for context
let history_rows = sqlx::query(
@@ -1599,9 +1758,14 @@ pub async fn chat_with_tutor(
}
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
@@ -1625,12 +1789,11 @@ pub async fn chat_with_tutor(
6. Responde en el mismo idioma de la pregunta del estudiante. \
\
CONTEXTO DE LA LECCIÓN E HISTORIAL:\n{}\n{}\n{}",
context,
memory_context,
kb_context
context, memory_context, kb_context
);
let response = client.post(&url)
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
@@ -1643,15 +1806,27 @@ pub async fn chat_with_tutor(
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error en la solicitud de IA: {}", e),
)
})?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", err_body)));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error de la API de IA: {}", err_body),
));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al analizar la respuesta de la IA: {}", e),
)
})?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
@@ -1659,16 +1834,15 @@ pub async fn chat_with_tutor(
.to_string();
// Save assistant response
let _ = sqlx::query(
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
)
.bind(session_id)
.bind("assistant")
.bind(&tutor_response)
.execute(&pool)
.await;
let _ =
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
.bind(session_id)
.bind("assistant")
.bind(&tutor_response)
.execute(&pool)
.await;
Ok(Json(ChatResponse {
Ok(Json(ChatResponse {
response: tutor_response,
session_id,
}))
@@ -1683,23 +1857,27 @@ pub async fn get_lesson_feedback(
let user_id = claims.sub;
// 1. Fetch lesson context
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
let lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
// 2. Fetch user's grade for this lesson
let grade = sqlx::query_as::<_, common::models::UserGrade>(
"SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2"
"SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2",
)
.bind(user_id)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No se encontró calificación para esta lección".into()))?;
.ok_or((
StatusCode::BAD_REQUEST,
"No se encontró calificación para esta lección".into(),
))?;
let score_pct = (grade.score * 100.0) as i32;
@@ -1720,9 +1898,14 @@ pub async fn get_lesson_feedback(
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://ollama:11434".to_string());
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
@@ -1744,8 +1927,7 @@ pub async fn get_lesson_feedback(
6. Responde en español ya que la plataforma se usa principalmente en ese idioma. \
\
CONTEXTO DE LA LECCIÓN:\n{}",
score_pct,
context
score_pct, context
);
let response = client.post(&url)
@@ -1765,25 +1947,30 @@ pub async fn get_lesson_feedback(
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", err_body)));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error de la API de IA: {}", err_body),
));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al analizar la respuesta de la IA: {}", e),
)
})?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.")
.to_string();
Ok(Json(ChatResponse {
Ok(Json(ChatResponse {
response: tutor_response,
session_id: Uuid::nil(),
}))
}
pub async fn ingest_lesson_knowledge(
pool: &PgPool,
org_id: Uuid,
@@ -1791,17 +1978,20 @@ pub async fn ingest_lesson_knowledge(
content: &str,
) -> Result<(), sqlx::Error> {
// Split content into chunks of ~1000 characters for better RAG granularity
let chunks: Vec<&str> = content.as_bytes()
let chunks: Vec<&str> = content
.as_bytes()
.chunks(1000)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
for chunk in chunks {
if chunk.trim().is_empty() { continue; }
if chunk.trim().is_empty() {
continue;
}
sqlx::query(
"INSERT INTO knowledge_base (organization_id, source_type, source_id, content_chunk)
VALUES ($1, $2, $3, $4)"
VALUES ($1, $2, $3, $4)",
)
.bind(org_id)
.bind("lesson_content")
@@ -1820,9 +2010,9 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
let title = block.get("title").and_then(|t| t.as_str()).unwrap_or("");
block_content.push_str(&format!("\n--- Block: {} ({}) ---\n", title, block_type));
match block_type {
"description" | "fill-in-the-blanks" => {
if let Some(content) = block.get("content").and_then(|c| c.as_str()) {
@@ -1830,9 +2020,14 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
}
}
"quiz" => {
if let Some(questions) = block.get("quiz_data").and_then(|q| q.get("questions")).and_then(|qs| qs.as_array()) {
if let Some(questions) = block
.get("quiz_data")
.and_then(|q| q.get("questions"))
.and_then(|qs| qs.as_array())
{
for (i, q) in questions.iter().enumerate() {
let question_text = q.get("question").and_then(|qt| qt.as_str()).unwrap_or("");
let question_text =
q.get("question").and_then(|qt| qt.as_str()).unwrap_or("");
block_content.push_str(&format!("Q{}: {}\n", i + 1, question_text));
}
}
@@ -1842,12 +2037,17 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
for (i, p) in pairs.iter().enumerate() {
let left = p.get("left").and_then(|l| l.as_str()).unwrap_or("");
let right = p.get("right").and_then(|r| r.as_str()).unwrap_or("");
block_content.push_str(&format!("Pair {}: {} <-> {}\n", i + 1, left, right));
block_content.push_str(&format!(
"Pair {}: {} <-> {}\n",
i + 1,
left,
right
));
}
}
}
"ordering" => {
if let Some(items) = block.get("items").and_then(|i| i.as_array()) {
if let Some(items) = block.get("items").and_then(|i| i.as_array()) {
for (i, item) in items.iter().enumerate() {
let text = item.as_str().unwrap_or("");
block_content.push_str(&format!("Item {}: {}\n", i + 1, text));
@@ -1860,7 +2060,9 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
}
}
"code" => {
if let Some(instructions) = block.get("instructions").and_then(|i| i.as_str()) {
if let Some(instructions) =
block.get("instructions").and_then(|i| i.as_str())
{
block_content.push_str(&format!("Instructions: {}\n", instructions));
}
}
@@ -1870,8 +2072,12 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
}
}
"hotspot" => {
if let Some(description) = block.get("description").and_then(|d| d.as_str()) {
block_content.push_str(&format!("Hotspot Activity Description: {}\n", description));
if let Some(description) = block.get("description").and_then(|d| d.as_str())
{
block_content.push_str(&format!(
"Hotspot Activity Description: {}\n",
description
));
}
}
_ => {}
@@ -0,0 +1,124 @@
use axum::{Json, extract::State, http::StatusCode};
use common::auth::Claims;
use common::middleware::Org;
use common::models::Course;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct CreatePaymentPayload {
pub course_id: Uuid,
}
#[derive(Serialize)]
pub struct PaymentPreferenceResponse {
pub preference_id: String,
pub init_point: String,
}
pub async fn create_payment_preference(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<CreatePaymentPayload>,
) -> Result<Json<PaymentPreferenceResponse>, (StatusCode, String)> {
let user_id = claims.sub;
let course_id = payload.course_id;
// 1. Get Course details
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
if course.price <= 0.0 {
return Err((StatusCode::BAD_REQUEST, "Course is free".into()));
}
// 2. Create a pending transaction
let transaction_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO transactions (id, organization_id, user_id, course_id, amount, currency, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')"
)
.bind(transaction_id)
.bind(org_ctx.id)
.bind(user_id)
.bind(course_id)
.bind(course.price)
.bind(&course.currency)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Call Mercado Pago API (Mocked for now as per plan)
let preference_id = format!("pref_{}", Uuid::new_v4().simple());
let init_point = format!(
"https://www.mercadopago.cl/checkout/v1/redirect?pref_id={}",
preference_id
);
// Update transaction with provider reference
sqlx::query("UPDATE transactions SET provider_reference = $1 WHERE id = $2")
.bind(&preference_id)
.bind(transaction_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(PaymentPreferenceResponse {
preference_id,
init_point,
}))
}
#[derive(Deserialize)]
pub struct MPWebhookPayload {
pub action: String,
pub data: MPWebhookData,
}
#[derive(Deserialize)]
pub struct MPWebhookData {
pub id: String,
}
pub async fn mercadopago_webhook(
State(pool): State<PgPool>,
Json(payload): Json<MPWebhookPayload>,
) -> Result<StatusCode, StatusCode> {
if payload.action != "payment.created" && payload.action != "payment.updated" {
return Ok(StatusCode::OK);
}
let payment_id = payload.data.id;
// Simplified success logic for the mock
let transaction: Option<(Uuid, Uuid)> =
sqlx::query_as("SELECT user_id, course_id FROM transactions WHERE provider_reference = $1")
.bind(&payment_id)
.fetch_optional(&pool)
.await
.unwrap_or(None);
if let Some((user_id, course_id)) = transaction {
sqlx::query("UPDATE transactions SET status = 'success', updated_at = NOW() WHERE provider_reference = $1")
.bind(&payment_id)
.execute(&pool)
.await
.ok();
// Auto-enroll the user
sqlx::query("INSERT INTO enrollments (id, user_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
.bind(Uuid::new_v4())
.bind(user_id)
.bind(course_id)
.execute(&pool)
.await
.ok();
}
Ok(StatusCode::OK)
}
+64 -19
View File
@@ -1,11 +1,12 @@
mod db_util;
mod handlers;
mod handlers_discussions;
mod handlers_announcements;
mod handlers_discussions;
mod handlers_payments;
use axum::{
Router, middleware,
routing::{get, post, put, delete},
routing::{delete, get, post, put},
};
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
@@ -48,6 +49,10 @@ async fn main() {
let protected_routes = Router::new()
.route("/enroll", post(handlers::enroll_user))
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
.route(
"/payments/preference",
post(handlers_payments::create_payment_preference),
)
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route("/grades", post(handlers::submit_lesson_score))
@@ -77,10 +82,7 @@ async fn main() {
"/lessons/{id}/interactions",
post(handlers::record_interaction),
)
.route(
"/lessons/{id}/heatmap",
get(handlers::get_lesson_heatmap),
)
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
@@ -91,21 +93,60 @@ async fn main() {
post(handlers::mark_notification_as_read),
)
// Discussion Forums Routes
.route("/courses/{id}/discussions", get(handlers_discussions::list_threads))
.route("/courses/{id}/discussions", post(handlers_discussions::create_thread))
.route("/discussions/{id}", get(handlers_discussions::get_thread_detail))
.route("/discussions/{id}/pin", post(handlers_discussions::pin_thread))
.route("/discussions/{id}/lock", post(handlers_discussions::lock_thread))
.route("/discussions/{id}/posts", post(handlers_discussions::create_post))
.route("/posts/{id}/endorse", post(handlers_discussions::endorse_post))
.route(
"/courses/{id}/discussions",
get(handlers_discussions::list_threads),
)
.route(
"/courses/{id}/discussions",
post(handlers_discussions::create_thread),
)
.route(
"/discussions/{id}",
get(handlers_discussions::get_thread_detail),
)
.route(
"/discussions/{id}/pin",
post(handlers_discussions::pin_thread),
)
.route(
"/discussions/{id}/lock",
post(handlers_discussions::lock_thread),
)
.route(
"/discussions/{id}/posts",
post(handlers_discussions::create_post),
)
.route(
"/posts/{id}/endorse",
post(handlers_discussions::endorse_post),
)
.route("/posts/{id}/vote", post(handlers_discussions::vote_post))
.route("/discussions/{id}/subscribe", post(handlers_discussions::subscribe_thread))
.route("/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread))
.route(
"/discussions/{id}/subscribe",
post(handlers_discussions::subscribe_thread),
)
.route(
"/discussions/{id}/unsubscribe",
post(handlers_discussions::unsubscribe_thread),
)
// Announcements
.route("/courses/{id}/announcements", get(handlers_announcements::list_announcements))
.route("/courses/{id}/announcements", post(handlers_announcements::create_announcement))
.route("/announcements/{id}", put(handlers_announcements::update_announcement))
.route("/announcements/{id}", delete(handlers_announcements::delete_announcement))
.route(
"/courses/{id}/announcements",
get(handlers_announcements::list_announcements),
)
.route(
"/courses/{id}/announcements",
post(handlers_announcements::create_announcement),
)
.route(
"/announcements/{id}",
put(handlers_announcements::update_announcement),
)
.route(
"/announcements/{id}",
delete(handlers_announcements::delete_announcement),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
));
@@ -115,6 +156,10 @@ async fn main() {
.route("/ingest", post(handlers::ingest_course))
.route("/auth/register", post(handlers::register))
.route("/auth/login", post(handlers::login))
.route(
"/payments/mercadopago/webhook",
post(handlers_payments::mercadopago_webhook),
)
.merge(protected_routes)
.layer(cors)
.with_state(pool);
+39 -2
View File
@@ -15,6 +15,8 @@ pub struct Course {
pub end_date: Option<DateTime<Utc>>,
pub passing_percentage: i32,
pub certificate_template: Option<String>,
pub price: f64,
pub currency: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -138,6 +140,20 @@ pub struct Asset {
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Transaction {
pub id: Uuid,
pub organization_id: Uuid,
pub user_id: Uuid,
pub course_id: Uuid,
pub amount: f64,
pub currency: String,
pub status: String, // "pending", "success", "failure"
pub provider_reference: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
@@ -419,8 +435,6 @@ pub struct AnnouncementWithAuthor {
pub author_avatar: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -490,6 +504,8 @@ mod tests {
end_date: None,
passing_percentage: 70,
certificate_template: None,
price: 0.0,
currency: "USD".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
},
@@ -501,6 +517,8 @@ mod tests {
primary_color: None,
secondary_color: None,
certificate_template: None,
platform_name: None,
favicon_url: None,
created_at: Utc::now(),
updated_at: Utc::now(),
},
@@ -508,6 +526,25 @@ mod tests {
modules: vec![pub_module],
};
let course_with_price = Course {
id: course_id,
organization_id: Uuid::new_v4(),
title: "Test Course".to_string(),
description: None,
instructor_id: Uuid::new_v4(),
pacing_mode: "self_paced".to_string(),
start_date: None,
end_date: None,
passing_percentage: 70,
certificate_template: None,
price: 29.99,
currency: "USD".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(course_with_price.price, 29.99);
let serialized = serde_json::to_string(&pub_course).unwrap();
let deserialized: PublishedCourse = serde_json::from_str(&serialized).unwrap();
+97 -31
View File
@@ -16,6 +16,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loadingAI, setLoadingAI] = useState(false);
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
const [isEnrolled, setIsEnrolled] = useState(false);
useEffect(() => {
const fetchData = async () => {
@@ -27,6 +28,9 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
if (user) {
const grades = await lmsApi.getUserGrades(user.id, params.id);
setUserGrades(grades);
const enrollmentData = await lmsApi.getEnrollments(user.id);
setIsEnrolled(enrollmentData.some(e => e.course_id === params.id));
}
} catch (err) {
console.error(err);
@@ -44,6 +48,30 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
.finally(() => setLoadingAI(false));
}, [params.id, user]);
const handleEnrollOrBuy = async () => {
if (!user) {
window.location.href = "/auth/login";
return;
}
try {
await lmsApi.enroll(params.id, user.id);
setIsEnrolled(true);
} catch (err: any) {
if (err.message.includes("Payment Required")) {
try {
const { init_point } = await lmsApi.createPaymentPreference(params.id);
window.location.href = init_point;
} catch (pErr) {
console.error("Falló la creación de preferencia de pago", pErr);
alert("No se pudo iniciar el proceso de pago.");
}
} else {
console.error("Falló la inscripción", err);
}
}
};
if (loading) {
return (
<div className="max-w-4xl mx-auto px-6 py-20 animate-pulse">
@@ -131,6 +159,23 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
<div className="flex gap-2">
{!isEnrolled && (
<button
onClick={handleEnrollOrBuy}
className="btn-premium px-8 py-3 !bg-blue-600 !text-white shadow-lg shadow-blue-500/20 active:scale-95 flex items-center gap-2"
>
{courseData.price > 0 ? (
<>
<span className="font-black">{courseData.currency} {courseData.price.toFixed(0)}</span>
Comprar Ahora
</>
) : (
<>
<CheckCircle2 size={16} /> Inscribirse Gratis
</>
)}
</button>
)}
<Link href={`/courses/${params.id}/calendar`}>
<button className="px-6 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
<Calendar size={16} /> Cronología
@@ -217,41 +262,62 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson) => (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
isEnrolled ? (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
</div>
</div>
</Link>
) : (
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-white/5 opacity-60 cursor-pointer hover:bg-white/5 transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center">
<Clock size={18} className="text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-500">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-600 flex items-center gap-1">
Contenido Protegido
</span>
</div>
</div>
<div className="flex items-center gap-2 text-gray-600 font-bold text-[10px] uppercase tracking-widest">
<span>Bloqueado</span>
</div>
</div>
</div>
</Link>
)
))}
</div>
</div>
@@ -262,6 +328,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
<div className="mt-20">
<DiscussionBoard courseId={params.id} />
</div>
</div>
</div >
);
}
+30 -8
View File
@@ -58,17 +58,28 @@ export default function CatalogPage() {
fetchData();
}, [user]);
const handleEnroll = async (courseId: string) => {
const handleEnrollOrBuy = async (course: Course) => {
if (!user) {
router.push("/auth/login");
return;
}
try {
await lmsApi.enroll(courseId, user.id);
setEnrollments(prev => [...prev, courseId]);
} catch (err) {
console.error("Falló la inscripción", err);
await lmsApi.enroll(course.id, user.id);
setEnrollments(prev => [...prev, course.id]);
} catch (err: any) {
// Check for 402 Payment Required
if (err.message.includes("Payment Required")) {
try {
const { init_point } = await lmsApi.createPaymentPreference(course.id);
window.location.href = init_point;
} catch (pErr) {
console.error("Falló la creación de preferencia de pago", pErr);
alert("No se pudo iniciar el proceso de pago.");
}
} else {
console.error("Falló la inscripción", err);
}
}
};
@@ -242,11 +253,22 @@ export default function CatalogPage() {
</Link>
) : (
<button
onClick={() => handleEnroll(course.id)}
onClick={() => handleEnrollOrBuy(course)}
className="btn-premium w-full group-hover:scale-[1.02] transition-transform flex items-center justify-center gap-2 group/btn"
>
<CheckCircle2 size={18} className="text-white/50 group-hover/btn:text-white transition-colors" />
Inscribirse Gratis
{course.price > 0 ? (
<>
<span className="font-black text-lg mr-2">
{course.currency} {course.price.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}
</span>
Comprar Ahora
</>
) : (
<>
<CheckCircle2 size={18} className="text-white/50 group-hover/btn:text-white transition-colors" />
Inscribirse Gratis
</>
)}
</button>
)}
</div>
+14
View File
@@ -55,9 +55,16 @@ export interface Course {
pacing_mode: string;
start_date?: string;
end_date?: string;
price: number;
currency: string;
created_at: string;
}
export interface PaymentPreferenceResponse {
preference_id: string;
init_point: string;
}
export interface QuizQuestion {
id: string;
question: string;
@@ -372,6 +379,13 @@ export const lmsApi = {
return apiFetch(`/enrollments/${userId}`);
},
async createPaymentPreference(courseId: string): Promise<PaymentPreferenceResponse> {
return apiFetch('/payments/preference', {
method: 'POST',
body: JSON.stringify({ course_id: courseId })
});
},
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
return apiFetch('/grades', {
method: 'POST',
@@ -35,6 +35,8 @@ export default function CourseSettingsPage() {
const [endDate, setEndDate] = useState("");
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [price, setPrice] = useState(0);
const [currency, setCurrency] = useState("USD");
useEffect(() => {
const fetchCourse = async () => {
@@ -46,6 +48,8 @@ export default function CourseSettingsPage() {
setPacingMode(data.pacing_mode || "self_paced");
setStartDate(data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : "");
setEndDate(data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : "");
setPrice(data.price || 0);
setCurrency(data.currency || "USD");
} catch (err) {
console.error("Failed to load course", err);
} finally {
@@ -63,7 +67,9 @@ export default function CourseSettingsPage() {
certificate_template: certificateTemplate,
pacing_mode: pacingMode,
start_date: startDate ? new Date(startDate).toISOString() : undefined,
end_date: endDate ? new Date(endDate).toISOString() : undefined
end_date: endDate ? new Date(endDate).toISOString() : undefined,
price: price,
currency: currency
});
setCourse(updated);
alert("Course settings updated successfully!");
@@ -289,6 +295,53 @@ export default function CourseSettingsPage() {
</div>
</section>
{/* Course Pricing Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400">
<span className="text-xl font-bold">$</span>
</div>
<h2 className="text-2xl font-black">Course Pricing</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<label className="block text-sm font-bold text-gray-300">Price</label>
<div className="relative">
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(parseFloat(e.target.value))}
className="w-full bg-black/30 border border-white/10 rounded-xl py-3 px-4 text-white focus:outline-none focus:border-blue-500 transition-colors"
placeholder="0.00"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 font-bold">
{currency}
</div>
</div>
<p className="text-xs text-gray-500">Set to 0 for a free course.</p>
</div>
<div className="space-y-4">
<label className="block text-sm font-bold text-gray-300">Currency</label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-3 px-4 text-white focus:outline-none focus:border-blue-500 transition-colors appearance-none"
>
<option value="USD">USD - US Dollar</option>
<option value="CLP">CLP - Chilean Peso</option>
<option value="ARS">ARS - Argentine Peso</option>
<option value="BRL">BRL - Brazilian Real</option>
<option value="MXN">MXN - Mexican Peso</option>
<option value="COP">COP - Colombian Peso</option>
</select>
</div>
</div>
</section>
{/* Certificate Template Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
+2
View File
@@ -31,6 +31,8 @@ export interface Course {
end_date?: string;
passing_percentage: number;
certificate_template?: string;
price: number;
currency: string;
created_at: string;
updated_at: string;
modules?: Module[];