feat: Implement core monetization features including course pricing, payment preference creation, and transaction management with Mercado Pago integration.
This commit is contained in:
+23
-3
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user