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.
|
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
||||||
- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
|
- [ ] **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**.
|
**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**:
|
**Próximas Prioridades**:
|
||||||
1. **Course Wiki**: Espacio colaborativo para documentación de cursos.
|
1. **Monetización Core**: Preparación de infraestructura para pagos y precios.
|
||||||
2. **Student Notes**: Anotaciones personales exportables.
|
2. **LTI 1.3 Research**: Bases para la interoperabilidad.
|
||||||
3. **Course Teams**: Permisos granulares para múltiples instructores.
|
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
|
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(target_org_id)
|
||||||
.bind(instructor_id)
|
.bind(instructor_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(pacing_mode)
|
.bind(pacing_mode)
|
||||||
|
.bind(price)
|
||||||
|
.bind(currency)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -358,6 +370,17 @@ pub async fn update_course(
|
|||||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
.or(existing.end_date);
|
.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
|
// BEGIN TRANSACTION
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
@@ -375,7 +398,7 @@ pub async fn update_course(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
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(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -386,6 +409,8 @@ pub async fn update_course(
|
|||||||
.bind(start_date)
|
.bind(start_date)
|
||||||
.bind(end_date)
|
.bind(end_date)
|
||||||
.bind(certificate_template)
|
.bind(certificate_template)
|
||||||
|
.bind(price)
|
||||||
|
.bind(currency)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State, Multipart},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
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 course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
let user_id = claims.sub;
|
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
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
@@ -130,8 +154,12 @@ pub async fn register(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AuthPayload>,
|
Json(payload): Json<AuthPayload>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
let password_hash = hash(payload.password, DEFAULT_COST).map_err(|_| {
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar la contraseña".into()))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error al procesar la contraseña".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let full_name = payload.full_name.unwrap_or_else(|| {
|
let full_name = payload.full_name.unwrap_or_else(|| {
|
||||||
payload
|
payload
|
||||||
@@ -262,7 +290,11 @@ pub async fn get_course_catalog(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Query(query): Query<CatalogQuery>,
|
Query(query): Query<CatalogQuery>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> 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) {
|
let courses = match (query.organization_id, query.user_id) {
|
||||||
(Some(org_id), Some(user_id)) => {
|
(Some(org_id), Some(user_id)) => {
|
||||||
sqlx::query_as::<_, Course>(
|
sqlx::query_as::<_, Course>(
|
||||||
@@ -346,8 +378,8 @@ pub async fn ingest_course(
|
|||||||
|
|
||||||
// 2. Upsert Course
|
// 2. Upsert Course
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode)
|
"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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
@@ -358,7 +390,9 @@ pub async fn ingest_course(
|
|||||||
certificate_template = EXCLUDED.certificate_template,
|
certificate_template = EXCLUDED.certificate_template,
|
||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
organization_id = EXCLUDED.organization_id,
|
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.id)
|
||||||
.bind(&payload.course.title)
|
.bind(&payload.course.title)
|
||||||
@@ -371,6 +405,8 @@ pub async fn ingest_course(
|
|||||||
.bind(payload.course.updated_at)
|
.bind(payload.course.updated_at)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.bind(&payload.course.pacing_mode)
|
.bind(&payload.course.pacing_mode)
|
||||||
|
.bind(payload.course.price)
|
||||||
|
.bind(&payload.course.currency)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -471,7 +507,7 @@ pub async fn ingest_course(
|
|||||||
}
|
}
|
||||||
// Also ingest summary as a high-relevance chunk
|
// Also ingest summary as a high-relevance chunk
|
||||||
if let Some(summary) = &lesson.summary {
|
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)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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
|
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
|
// 4. Fetch Grading Categories
|
||||||
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
@@ -546,7 +589,11 @@ pub async fn get_course_outline(
|
|||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -563,17 +610,43 @@ pub async fn get_course_outline(
|
|||||||
|
|
||||||
pub async fn get_lesson_content(
|
pub async fn get_lesson_content(
|
||||||
Org(_org_ctx): Org,
|
Org(_org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
tracing::info!("get_lesson_content: fetching lesson {}", id);
|
tracing::info!(
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
"get_lesson_content: fetching lesson {} for user {}",
|
||||||
.bind(id)
|
id,
|
||||||
.fetch_one(&pool)
|
claims.sub
|
||||||
.await
|
);
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
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(
|
pub async fn get_user_enrollments(
|
||||||
@@ -581,7 +654,11 @@ pub async fn get_user_enrollments(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
) -> 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 =
|
let enrollments =
|
||||||
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
|
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -1134,12 +1211,13 @@ pub async fn get_recommendations(
|
|||||||
// It's safer to join or use existing working query.
|
// It's safer to join or use existing working query.
|
||||||
// I will assume the original query was correct for the schema in this environment.
|
// I will assume the original query was correct for the schema in this environment.
|
||||||
|
|
||||||
let lessons =
|
let lessons = sqlx::query_as::<_, LessonContext>(
|
||||||
sqlx::query_as::<_, LessonContext>("SELECT id, title, metadata FROM lessons WHERE course_id = $1")
|
"SELECT id, title, metadata FROM lessons WHERE course_id = $1",
|
||||||
.bind(course_id)
|
)
|
||||||
.fetch_all(&pool)
|
.bind(course_id)
|
||||||
.await
|
.fetch_all(&pool)
|
||||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 3. Prepare AI context with Skills Analysis
|
// 3. Prepare AI context with Skills Analysis
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -1157,8 +1235,7 @@ pub async fn get_recommendations(
|
|||||||
|
|
||||||
performance_summary.push_str(&format!(
|
performance_summary.push_str(&format!(
|
||||||
"- Lesson: {}, Score: {}%\n",
|
"- Lesson: {}, Score: {}%\n",
|
||||||
lesson_title,
|
lesson_title, score_percent as i32
|
||||||
score_percent as i32
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Skill Analysis
|
// Skill Analysis
|
||||||
@@ -1257,9 +1334,14 @@ pub async fn evaluate_audio_response(
|
|||||||
|
|
||||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
let (url, auth_header, model) = if provider == "local" {
|
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());
|
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 {
|
} else {
|
||||||
(
|
(
|
||||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
"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
|
payload.prompt, payload.keywords, payload.transcript
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client.post(&url)
|
let response = client
|
||||||
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
@@ -1295,8 +1378,12 @@ pub async fn evaluate_audio_response(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let ai_data: serde_json::Value = response.json().await
|
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI response parse failed: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("AI response parse failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let grading: AudioGradingResponse = serde_json::from_value(
|
let grading: AudioGradingResponse = serde_json::from_value(
|
||||||
ai_data["choices"][0]["message"]["content"]
|
ai_data["choices"][0]["message"]["content"]
|
||||||
@@ -1325,65 +1412,109 @@ pub async fn evaluate_audio_file(
|
|||||||
let mut audio_data = Vec::new();
|
let mut audio_data = Vec::new();
|
||||||
let mut filename = "audio.webm".to_string();
|
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();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"prompt" => prompt = field.text().await.unwrap_or_default(),
|
"prompt" => prompt = field.text().await.unwrap_or_default(),
|
||||||
"keywords" => keywords_str = field.text().await.unwrap_or_default(),
|
"keywords" => keywords_str = field.text().await.unwrap_or_default(),
|
||||||
"file" => {
|
"file" => {
|
||||||
filename = field.file_name().unwrap_or("audio.webm").to_string();
|
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() {
|
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
|
// 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 client = reqwest::Client::new();
|
||||||
|
|
||||||
let form = reqwest::multipart::Form::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("model", "whisper-1")
|
||||||
.text("response_format", "json");
|
.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)
|
.multipart(form)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if !response.status().is_success() {
|
||||||
let err_body = response.text().await.unwrap_or_default();
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
tracing::error!("Whisper error: {}", err_body);
|
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
|
let transcription_result: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de Whisper: {}", 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() {
|
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('[') {
|
let keywords: Vec<String> = if keywords_str.trim().starts_with('[') {
|
||||||
serde_json::from_str(&keywords_str).unwrap_or_default()
|
serde_json::from_str(&keywords_str).unwrap_or_default()
|
||||||
} else {
|
} 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
|
// 2. Perform AI Grading
|
||||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
let (url, auth_header, model) = if provider == "local" {
|
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());
|
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 {
|
} else {
|
||||||
(
|
(
|
||||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
@@ -1404,7 +1535,8 @@ pub async fn evaluate_audio_file(
|
|||||||
prompt, keywords, transcript
|
prompt, keywords, transcript
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client.post(&url)
|
let response = client
|
||||||
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
@@ -1417,10 +1549,19 @@ pub async fn evaluate_audio_file(
|
|||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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
|
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al analizar la respuesta de la IA: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let grading: AudioGradingResponse = serde_json::from_value(
|
let grading: AudioGradingResponse = serde_json::from_value(
|
||||||
ai_data["choices"][0]["message"]["content"]
|
ai_data["choices"][0]["message"]["content"]
|
||||||
@@ -1458,19 +1599,25 @@ pub async fn chat_with_tutor(
|
|||||||
Json(payload): Json<ChatPayload>,
|
Json(payload): Json<ChatPayload>,
|
||||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||||
// 1. Fetch lesson context (summary and transcription)
|
// 1. Fetch lesson context (summary and transcription)
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson =
|
||||||
.bind(lesson_id)
|
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(org_ctx.id)
|
.bind(lesson_id)
|
||||||
.fetch_one(&pool)
|
.bind(org_ctx.id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
|
||||||
|
|
||||||
// 1.5 Fetch previous lessons in the course for context
|
// 1.5 Fetch previous lessons in the course for context
|
||||||
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
|
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
|
||||||
.bind(lesson.module_id)
|
.bind(lesson.module_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.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(
|
let previous_lessons = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -1487,7 +1634,12 @@ pub async fn chat_with_tutor(
|
|||||||
.bind(lesson.position)
|
.bind(lesson.position)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.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();
|
let mut history_context = String::new();
|
||||||
if !previous_lessons.is_empty() {
|
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{}",
|
"CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}",
|
||||||
lesson.title,
|
lesson.title,
|
||||||
lesson.summary.as_deref().unwrap_or_default(),
|
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,
|
block_content,
|
||||||
history_context
|
history_context
|
||||||
);
|
);
|
||||||
@@ -1543,15 +1699,18 @@ pub async fn chat_with_tutor(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save user message
|
// Save user message
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
|
||||||
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
|
.bind(session_id)
|
||||||
)
|
.bind("user")
|
||||||
.bind(session_id)
|
.bind(&payload.message)
|
||||||
.bind("user")
|
.execute(&pool)
|
||||||
.bind(&payload.message)
|
.await
|
||||||
.execute(&pool)
|
.map_err(|_| {
|
||||||
.await
|
(
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save user message".into()))?;
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to save user message".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Fetch last 6 messages for context
|
// Fetch last 6 messages for context
|
||||||
let history_rows = sqlx::query(
|
let history_rows = sqlx::query(
|
||||||
@@ -1599,9 +1758,14 @@ pub async fn chat_with_tutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (url, auth_header, model) = if provider == "local" {
|
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());
|
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 {
|
} else {
|
||||||
(
|
(
|
||||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
"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. \
|
6. Responde en el mismo idioma de la pregunta del estudiante. \
|
||||||
\
|
\
|
||||||
CONTEXTO DE LA LECCIÓN E HISTORIAL:\n{}\n{}\n{}",
|
CONTEXTO DE LA LECCIÓN E HISTORIAL:\n{}\n{}\n{}",
|
||||||
context,
|
context, memory_context, kb_context
|
||||||
memory_context,
|
|
||||||
kb_context
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client.post(&url)
|
let response = client
|
||||||
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
@@ -1643,15 +1806,27 @@ pub async fn chat_with_tutor(
|
|||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if !response.status().is_success() {
|
||||||
let err_body = response.text().await.unwrap_or_default();
|
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
|
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al analizar la respuesta de la IA: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let tutor_response = ai_data["choices"][0]["message"]["content"]
|
let tutor_response = ai_data["choices"][0]["message"]["content"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -1659,14 +1834,13 @@ pub async fn chat_with_tutor(
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Save assistant response
|
// Save assistant response
|
||||||
let _ = sqlx::query(
|
let _ =
|
||||||
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
|
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
|
||||||
)
|
.bind(session_id)
|
||||||
.bind(session_id)
|
.bind("assistant")
|
||||||
.bind("assistant")
|
.bind(&tutor_response)
|
||||||
.bind(&tutor_response)
|
.execute(&pool)
|
||||||
.execute(&pool)
|
.await;
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(Json(ChatResponse {
|
Ok(Json(ChatResponse {
|
||||||
response: tutor_response,
|
response: tutor_response,
|
||||||
@@ -1683,23 +1857,27 @@ pub async fn get_lesson_feedback(
|
|||||||
let user_id = claims.sub;
|
let user_id = claims.sub;
|
||||||
|
|
||||||
// 1. Fetch lesson context
|
// 1. Fetch lesson context
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson =
|
||||||
.bind(lesson_id)
|
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(org_ctx.id)
|
.bind(lesson_id)
|
||||||
.fetch_one(&pool)
|
.bind(org_ctx.id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
|
||||||
|
|
||||||
// 2. Fetch user's grade for this lesson
|
// 2. Fetch user's grade for this lesson
|
||||||
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
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(user_id)
|
||||||
.bind(lesson_id)
|
.bind(lesson_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.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;
|
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 client = reqwest::Client::new();
|
||||||
|
|
||||||
let (url, auth_header, model) = if provider == "local" {
|
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());
|
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 {
|
} else {
|
||||||
(
|
(
|
||||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
"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. \
|
6. Responde en español ya que la plataforma se usa principalmente en ese idioma. \
|
||||||
\
|
\
|
||||||
CONTEXTO DE LA LECCIÓN:\n{}",
|
CONTEXTO DE LA LECCIÓN:\n{}",
|
||||||
score_pct,
|
score_pct, context
|
||||||
context
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client.post(&url)
|
let response = client.post(&url)
|
||||||
@@ -1765,11 +1947,18 @@ pub async fn get_lesson_feedback(
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let err_body = response.text().await.unwrap_or_default();
|
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
|
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al analizar la respuesta de la IA: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let tutor_response = ai_data["choices"][0]["message"]["content"]
|
let tutor_response = ai_data["choices"][0]["message"]["content"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -1782,8 +1971,6 @@ pub async fn get_lesson_feedback(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn ingest_lesson_knowledge(
|
pub async fn ingest_lesson_knowledge(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
org_id: Uuid,
|
org_id: Uuid,
|
||||||
@@ -1791,17 +1978,20 @@ pub async fn ingest_lesson_knowledge(
|
|||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
// Split content into chunks of ~1000 characters for better RAG granularity
|
// 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)
|
.chunks(1000)
|
||||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
if chunk.trim().is_empty() { continue; }
|
if chunk.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO knowledge_base (organization_id, source_type, source_id, content_chunk)
|
"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(org_id)
|
||||||
.bind("lesson_content")
|
.bind("lesson_content")
|
||||||
@@ -1830,9 +2020,14 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"quiz" => {
|
"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() {
|
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));
|
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() {
|
for (i, p) in pairs.iter().enumerate() {
|
||||||
let left = p.get("left").and_then(|l| l.as_str()).unwrap_or("");
|
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("");
|
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" => {
|
"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() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
let text = item.as_str().unwrap_or("");
|
let text = item.as_str().unwrap_or("");
|
||||||
block_content.push_str(&format!("Item {}: {}\n", i + 1, text));
|
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" => {
|
"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));
|
block_content.push_str(&format!("Instructions: {}\n", instructions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1870,8 +2072,12 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"hotspot" => {
|
"hotspot" => {
|
||||||
if let Some(description) = block.get("description").and_then(|d| d.as_str()) {
|
if let Some(description) = block.get("description").and_then(|d| d.as_str())
|
||||||
block_content.push_str(&format!("Hotspot Activity Description: {}\n", description));
|
{
|
||||||
|
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 db_util;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_discussions;
|
|
||||||
mod handlers_announcements;
|
mod handlers_announcements;
|
||||||
|
mod handlers_discussions;
|
||||||
|
mod handlers_payments;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{get, post, put, delete},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
@@ -48,6 +49,10 @@ async fn main() {
|
|||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.route("/enroll", post(handlers::enroll_user))
|
.route("/enroll", post(handlers::enroll_user))
|
||||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
.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("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||||
.route("/grades", post(handlers::submit_lesson_score))
|
.route("/grades", post(handlers::submit_lesson_score))
|
||||||
@@ -77,10 +82,7 @@ async fn main() {
|
|||||||
"/lessons/{id}/interactions",
|
"/lessons/{id}/interactions",
|
||||||
post(handlers::record_interaction),
|
post(handlers::record_interaction),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||||
"/lessons/{id}/heatmap",
|
|
||||||
get(handlers::get_lesson_heatmap),
|
|
||||||
)
|
|
||||||
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
||||||
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
|
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
|
||||||
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
|
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
|
||||||
@@ -91,21 +93,60 @@ async fn main() {
|
|||||||
post(handlers::mark_notification_as_read),
|
post(handlers::mark_notification_as_read),
|
||||||
)
|
)
|
||||||
// Discussion Forums Routes
|
// Discussion Forums Routes
|
||||||
.route("/courses/{id}/discussions", get(handlers_discussions::list_threads))
|
.route(
|
||||||
.route("/courses/{id}/discussions", post(handlers_discussions::create_thread))
|
"/courses/{id}/discussions",
|
||||||
.route("/discussions/{id}", get(handlers_discussions::get_thread_detail))
|
get(handlers_discussions::list_threads),
|
||||||
.route("/discussions/{id}/pin", post(handlers_discussions::pin_thread))
|
)
|
||||||
.route("/discussions/{id}/lock", post(handlers_discussions::lock_thread))
|
.route(
|
||||||
.route("/discussions/{id}/posts", post(handlers_discussions::create_post))
|
"/courses/{id}/discussions",
|
||||||
.route("/posts/{id}/endorse", post(handlers_discussions::endorse_post))
|
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("/posts/{id}/vote", post(handlers_discussions::vote_post))
|
||||||
.route("/discussions/{id}/subscribe", post(handlers_discussions::subscribe_thread))
|
.route(
|
||||||
.route("/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread))
|
"/discussions/{id}/subscribe",
|
||||||
|
post(handlers_discussions::subscribe_thread),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/discussions/{id}/unsubscribe",
|
||||||
|
post(handlers_discussions::unsubscribe_thread),
|
||||||
|
)
|
||||||
// Announcements
|
// Announcements
|
||||||
.route("/courses/{id}/announcements", get(handlers_announcements::list_announcements))
|
.route(
|
||||||
.route("/courses/{id}/announcements", post(handlers_announcements::create_announcement))
|
"/courses/{id}/announcements",
|
||||||
.route("/announcements/{id}", put(handlers_announcements::update_announcement))
|
get(handlers_announcements::list_announcements),
|
||||||
.route("/announcements/{id}", delete(handlers_announcements::delete_announcement))
|
)
|
||||||
|
.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(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
@@ -115,6 +156,10 @@ async fn main() {
|
|||||||
.route("/ingest", post(handlers::ingest_course))
|
.route("/ingest", post(handlers::ingest_course))
|
||||||
.route("/auth/register", post(handlers::register))
|
.route("/auth/register", post(handlers::register))
|
||||||
.route("/auth/login", post(handlers::login))
|
.route("/auth/login", post(handlers::login))
|
||||||
|
.route(
|
||||||
|
"/payments/mercadopago/webhook",
|
||||||
|
post(handlers_payments::mercadopago_webhook),
|
||||||
|
)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub struct Course {
|
|||||||
pub end_date: Option<DateTime<Utc>>,
|
pub end_date: Option<DateTime<Utc>>,
|
||||||
pub passing_percentage: i32,
|
pub passing_percentage: i32,
|
||||||
pub certificate_template: Option<String>,
|
pub certificate_template: Option<String>,
|
||||||
|
pub price: f64,
|
||||||
|
pub currency: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,20 @@ pub struct Asset {
|
|||||||
pub created_at: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -419,8 +435,6 @@ pub struct AnnouncementWithAuthor {
|
|||||||
pub author_avatar: Option<String>,
|
pub author_avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -490,6 +504,8 @@ mod tests {
|
|||||||
end_date: None,
|
end_date: None,
|
||||||
passing_percentage: 70,
|
passing_percentage: 70,
|
||||||
certificate_template: None,
|
certificate_template: None,
|
||||||
|
price: 0.0,
|
||||||
|
currency: "USD".to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
},
|
},
|
||||||
@@ -501,6 +517,8 @@ mod tests {
|
|||||||
primary_color: None,
|
primary_color: None,
|
||||||
secondary_color: None,
|
secondary_color: None,
|
||||||
certificate_template: None,
|
certificate_template: None,
|
||||||
|
platform_name: None,
|
||||||
|
favicon_url: None,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
},
|
},
|
||||||
@@ -508,6 +526,25 @@ mod tests {
|
|||||||
modules: vec![pub_module],
|
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 serialized = serde_json::to_string(&pub_course).unwrap();
|
||||||
let deserialized: PublishedCourse = serde_json::from_str(&serialized).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 [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||||
const [loadingAI, setLoadingAI] = useState(false);
|
const [loadingAI, setLoadingAI] = useState(false);
|
||||||
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
||||||
|
const [isEnrolled, setIsEnrolled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -27,6 +28,9 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
if (user) {
|
if (user) {
|
||||||
const grades = await lmsApi.getUserGrades(user.id, params.id);
|
const grades = await lmsApi.getUserGrades(user.id, params.id);
|
||||||
setUserGrades(grades);
|
setUserGrades(grades);
|
||||||
|
|
||||||
|
const enrollmentData = await lmsApi.getEnrollments(user.id);
|
||||||
|
setIsEnrolled(enrollmentData.some(e => e.course_id === params.id));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -44,6 +48,30 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
.finally(() => setLoadingAI(false));
|
.finally(() => setLoadingAI(false));
|
||||||
}, [params.id, user]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-6 py-20 animate-pulse">
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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`}>
|
<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">
|
<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
|
<Calendar size={16} /> Cronología
|
||||||
@@ -217,41 +262,62 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
|
|
||||||
<div className="grid gap-3 pl-14">
|
<div className="grid gap-3 pl-14">
|
||||||
{module.lessons.map((lesson) => (
|
{module.lessons.map((lesson) => (
|
||||||
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
isEnrolled ? (
|
||||||
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||||
<div className="flex items-center justify-between">
|
<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 gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
<div className="flex items-center gap-4">
|
||||||
{lesson.content_type === 'video' ? (
|
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||||
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
|
{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" />
|
) : (
|
||||||
)}
|
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||||
</div>
|
)}
|
||||||
<div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
|
<div>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
|
||||||
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
</span>
|
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
</div>
|
||||||
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
|
<div className="flex items-center gap-6">
|
||||||
{lesson.due_date && (
|
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
|
||||||
<div className="text-right hidden sm:block">
|
{lesson.due_date && (
|
||||||
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
|
<div className="text-right hidden sm:block">
|
||||||
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
|
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
|
||||||
{new Date(lesson.due_date).toLocaleDateString()}
|
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
|
||||||
</div>
|
{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>
|
||||||
)}
|
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<ChevronRight size={18} className="text-blue-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Link>
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,6 +328,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
<div className="mt-20">
|
<div className="mt-20">
|
||||||
<DiscussionBoard courseId={params.id} />
|
<DiscussionBoard courseId={params.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,28 @@ export default function CatalogPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleEnroll = async (courseId: string) => {
|
const handleEnrollOrBuy = async (course: Course) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.push("/auth/login");
|
router.push("/auth/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await lmsApi.enroll(courseId, user.id);
|
await lmsApi.enroll(course.id, user.id);
|
||||||
setEnrollments(prev => [...prev, courseId]);
|
setEnrollments(prev => [...prev, course.id]);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Falló la inscripción", err);
|
// 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>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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"
|
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" />
|
{course.price > 0 ? (
|
||||||
Inscribirse Gratis
|
<>
|
||||||
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,9 +55,16 @@ export interface Course {
|
|||||||
pacing_mode: string;
|
pacing_mode: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentPreferenceResponse {
|
||||||
|
preference_id: string;
|
||||||
|
init_point: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuizQuestion {
|
export interface QuizQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
question: string;
|
question: string;
|
||||||
@@ -372,6 +379,13 @@ export const lmsApi = {
|
|||||||
return apiFetch(`/enrollments/${userId}`);
|
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> {
|
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
|
||||||
return apiFetch('/grades', {
|
return apiFetch('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export default function CourseSettingsPage() {
|
|||||||
const [endDate, setEndDate] = useState("");
|
const [endDate, setEndDate] = useState("");
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [price, setPrice] = useState(0);
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
@@ -46,6 +48,8 @@ export default function CourseSettingsPage() {
|
|||||||
setPacingMode(data.pacing_mode || "self_paced");
|
setPacingMode(data.pacing_mode || "self_paced");
|
||||||
setStartDate(data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : "");
|
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] : "");
|
setEndDate(data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : "");
|
||||||
|
setPrice(data.price || 0);
|
||||||
|
setCurrency(data.currency || "USD");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load course", err);
|
console.error("Failed to load course", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -63,7 +67,9 @@ export default function CourseSettingsPage() {
|
|||||||
certificate_template: certificateTemplate,
|
certificate_template: certificateTemplate,
|
||||||
pacing_mode: pacingMode,
|
pacing_mode: pacingMode,
|
||||||
start_date: startDate ? new Date(startDate).toISOString() : undefined,
|
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);
|
setCourse(updated);
|
||||||
alert("Course settings updated successfully!");
|
alert("Course settings updated successfully!");
|
||||||
@@ -289,6 +295,53 @@ export default function CourseSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Certificate Template Section */}
|
||||||
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
|
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface Course {
|
|||||||
end_date?: string;
|
end_date?: string;
|
||||||
passing_percentage: number;
|
passing_percentage: number;
|
||||||
certificate_template?: string;
|
certificate_template?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
modules?: Module[];
|
modules?: Module[];
|
||||||
|
|||||||
Reference in New Issue
Block a user