From 4eb7ade40710e9d805f968373683f2369d079d97 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Sun, 15 Feb 2026 13:54:01 -0300 Subject: [PATCH] feat: Implement course monetization with Mercado Pago, update roadmap status, and refine discussion service handlers. --- README.md | 2 + roadmap.md | 17 ++--- .../lms-service/src/handlers_discussions.rs | 65 ++++++++++++------- web/experience/src/app/courses/[id]/page.tsx | 14 ++-- web/studio/package-lock.json | 14 +++- 5 files changed, 72 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3e85a8f..3ba35d3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero. - **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones. - **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo). +- **Course Monetization**: Integración con Mercado Pago para venta de cursos, con inscripciones automáticas y paneles de precios para instructores. ## Requisitos del Sistema @@ -581,6 +582,7 @@ Obtiene una lista de todas las organizaciones registradas. - **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions. - **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality. - **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support. +- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/roadmap.md b/roadmap.md index 7a109db..a04170c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -192,11 +192,13 @@ - [ ] **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. +## Fase 18: Monetización y Estandarización ✅ +- [x] **E-Commerce & Monetización**: (Completado) + - [x] Integración con Mercado Pago (Preferencia de pago y Webhooks). + - [x] Sistema de precios y moneda por curso. + - [x] Inscripción automática tras pago exitoso. + - [x] Verificación de seguridad de acceso a lecciones basada en inscripción. + - [x] Dashboard de transacciones básico en base de datos. - [ ] **Interoperabilidad**: - [ ] Implementación de LTI 1.3 (Tool Provider). - [ ] Conectividad con LMS externos (Moodle/Canvas). @@ -213,10 +215,9 @@ --- -**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**, **gestión de anuncios** y **monetización integrada con Mercado Pago**. **Próximas Prioridades**: -1. **Monetización Core**: Preparación de infraestructura para pagos y precios. -2. **LTI 1.3 Research**: Bases para la interoperabilidad. +1. **LTI 1.3 Research**: Bases para la interoperabilidad con LMS externos. 3. **Course Wiki**: Espacio colaborativo para documentación de cursos. 4. **Student Notes**: Anotaciones personales exportables. diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index 6129925..686fe88 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -5,10 +5,7 @@ use axum::{ }; use common::auth::Claims; use common::middleware::Org; -use common::models::{ - DiscussionThread, DiscussionPost, - ThreadWithAuthor, PostWithAuthor, -}; +use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWithAuthor}; use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; @@ -63,12 +60,12 @@ pub async fn list_threads( FROM discussion_threads t LEFT JOIN users u ON t.author_id = u.id LEFT JOIN discussion_posts p ON t.id = p.thread_id - WHERE t.course_id = $1 AND t.organization_id = $2" + WHERE t.course_id = $1 AND t.organization_id = $2", ); let mut bind_count = 2; - if let Some(lesson_id) = params.lesson_id { + if let Some(_lesson_id) = params.lesson_id { bind_count += 1; query.push_str(&format!(" AND t.lesson_id = ${}", bind_count)); } @@ -80,7 +77,9 @@ pub async fn list_threads( query.push_str(&format!(" AND t.author_id = ${}", bind_count)); } "unanswered" => { - query.push_str(" AND NOT EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id)"); + query.push_str( + " AND NOT EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id)", + ); } "resolved" => { query.push_str(" AND EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id AND is_endorsed = true)"); @@ -146,7 +145,7 @@ pub async fn create_thread( let _ = sqlx::query( "INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id) VALUES ($1, $2, $3) - ON CONFLICT DO NOTHING" + ON CONFLICT DO NOTHING", ) .bind(org_ctx.id) .bind(thread.id) @@ -181,7 +180,7 @@ pub async fn get_thread_detail( LEFT JOIN users u ON t.author_id = u.id LEFT JOIN discussion_posts p ON t.id = p.thread_id WHERE t.id = $1 AND t.organization_id = $2 - GROUP BY t.id, u.full_name, u.avatar_url" + GROUP BY t.id, u.full_name, u.avatar_url", ) .bind(thread_id) .bind(org_ctx.id) @@ -205,7 +204,13 @@ fn get_thread_posts_recursive<'a>( parent_id: Option, user_id: Uuid, org_id: Uuid, -) -> std::pin::Pin, (StatusCode, String)>> + Send + 'a>> { +) -> std::pin::Pin< + Box< + dyn std::future::Future, (StatusCode, String)>> + + Send + + 'a, + >, +> { Box::pin(async move { let parent_filter = match parent_id { Some(_) => "parent_post_id = $2", @@ -226,8 +231,7 @@ fn get_thread_posts_recursive<'a>( parent_filter ); - let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query) - .bind(thread_id); + let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query).bind(thread_id); if let Some(pid) = parent_id { sql_query = sql_query.bind(pid); @@ -242,7 +246,8 @@ fn get_thread_posts_recursive<'a>( // Recursively fetch replies for each post for post in &mut posts { - post.replies = get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?; + post.replies = + get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?; } Ok(posts) @@ -263,7 +268,10 @@ pub async fn pin_thread( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can pin threads".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can pin threads".to_string(), + )); } sqlx::query("UPDATE discussion_threads SET is_pinned = NOT is_pinned WHERE id = $1 AND organization_id = $2") @@ -290,7 +298,10 @@ pub async fn lock_thread( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can lock threads".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can lock threads".to_string(), + )); } sqlx::query("UPDATE discussion_threads SET is_locked = NOT is_locked WHERE id = $1 AND organization_id = $2") @@ -313,11 +324,12 @@ pub async fn create_post( Json(payload): Json, ) -> Result, (StatusCode, String)> { // Check if thread is locked - let thread = sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1") - .bind(thread_id) - .fetch_one(&pool) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + let thread = + sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1") + .bind(thread_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?; if thread.0 { return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string())); @@ -356,7 +368,10 @@ pub async fn endorse_post( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can endorse posts".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can endorse posts".to_string(), + )); } sqlx::query("UPDATE discussion_posts SET is_endorsed = NOT is_endorsed WHERE id = $1 AND organization_id = $2") @@ -385,7 +400,7 @@ pub async fn vote_post( "INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type) VALUES ($1, $2, $3, $4) ON CONFLICT (post_id, user_id) - DO UPDATE SET vote_type = EXCLUDED.vote_type" + DO UPDATE SET vote_type = EXCLUDED.vote_type", ) .bind(org_ctx.id) .bind(post_id) @@ -397,7 +412,7 @@ pub async fn vote_post( // Recalculate upvotes let upvote_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'" + "SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'", ) .bind(post_id) .fetch_one(&pool) @@ -425,7 +440,7 @@ pub async fn subscribe_thread( sqlx::query( "INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id) VALUES ($1, $2, $3) - ON CONFLICT DO NOTHING" + ON CONFLICT DO NOTHING", ) .bind(org_ctx.id) .bind(thread_id) @@ -445,7 +460,7 @@ pub async fn unsubscribe_thread( ) -> Result { sqlx::query( "DELETE FROM discussion_subscriptions - WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3" + WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3", ) .bind(thread_id) .bind(claims.sub) diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 4e13313..a6f4f74 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -89,7 +89,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } if (!courseData) return
Curso no encontrado.
; const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => { - const grade = userGrades.find(g => g.lesson_id === lessonId); + const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId); if (!grade) { return ; } @@ -125,7 +125,9 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }

-
{courseData.pacing_mode === 'instructor_led' ? : } {courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'} @@ -210,7 +212,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
) : ( - recommendations.map((rec, i) => ( + recommendations.map((rec: Recommendation, i: number) => (
@@ -251,7 +253,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
- {courseData.modules.map((module, idx) => ( + {courseData.modules.map((module: Module, idx: number) => (
@@ -261,7 +263,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
- {module.lessons.map((lesson) => ( + {module.lessons.map((lesson: any) => ( isEnrolled ? (
@@ -328,6 +330,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
-
+
); } diff --git a/web/studio/package-lock.json b/web/studio/package-lock.json index 6fab93d..53f0f06 100644 --- a/web/studio/package-lock.json +++ b/web/studio/package-lock.json @@ -593,6 +593,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -660,6 +661,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -1133,6 +1135,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2165,6 +2168,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2327,6 +2331,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3738,6 +3743,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5073,6 +5079,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5278,6 +5285,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5289,6 +5297,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5375,7 +5384,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -6262,6 +6272,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -6439,6 +6450,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"