feat: Implement course monetization with Mercado Pago, update roadmap status, and refine discussion service handlers.

This commit is contained in:
2026-02-15 13:54:01 -03:00
parent 34e72ae985
commit 4eb7ade407
5 changed files with 72 additions and 40 deletions
+2
View File
@@ -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.
+9 -8
View File
@@ -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.
@@ -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<Uuid>,
user_id: Uuid,
org_id: Uuid,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PostWithAuthor>, (StatusCode, String)>> + Send + 'a>> {
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<Vec<PostWithAuthor>, (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<CreatePostPayload>,
) -> Result<Json<DiscussionPost>, (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<StatusCode, (StatusCode, String)> {
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)
+8 -6
View File
@@ -89,7 +89,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
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 <Circle size={18} className="text-white/20" />;
}
@@ -125,7 +125,9 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</p>
<div className="flex flex-wrap items-center gap-4 mb-10">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led' ? 'bg-purple-500/10 border-purple-500/30 text-purple-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400'
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led'
? 'bg-purple-500/10 border-purple-500/30 text-purple-400'
: 'bg-blue-500/10 border-blue-500/30 text-blue-400'
}`}>
{courseData.pacing_mode === 'instructor_led' ? <Clock size={14} /> : <Info size={14} />}
{courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'}
@@ -210,7 +212,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
<div className="h-3 w-2/3 bg-white/10 rounded"></div>
</div>
) : (
recommendations.map((rec, i) => (
recommendations.map((rec: Recommendation, i: number) => (
<div key={i} className="glass-card border-white/5 hover:border-purple-500/30 transition-all p-6 group">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-3">
@@ -251,7 +253,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
<div className="space-y-12">
{courseData.modules.map((module, idx) => (
{courseData.modules.map((module: Module, idx: number) => (
<div key={module.id} className="relative">
<div className="flex items-center gap-4 mb-6">
<div className="w-10 h-10 rounded-xl glass border-blue-500/20 bg-blue-500/10 flex items-center justify-center">
@@ -261,7 +263,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson) => (
{module.lessons.map((lesson: any) => (
isEnrolled ? (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
@@ -328,6 +330,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
<div className="mt-20">
<DiscussionBoard courseId={params.id} />
</div>
</div >
</div>
);
}
+13 -1
View File
@@ -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"