diff --git a/README.md b/README.md index 59edfc2..b2949a6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Color-Coded Progress Navigation**: Sistema visual de seguimiento de progreso mediante colores (Verde: Completado, Amarillo: En Proceso, Rojo: Repetible) tanto a nivel de lección como de módulo. - **Adaptive Skill Analysis**: Motor de análisis de etiquetas que calcula la maestría de habilidades (Gramática, Vocabulario, etc.) para personalizar las recomendaciones de IA. - **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). ## Requisitos del Sistema @@ -388,7 +390,118 @@ Generador de reportes personalizados para exportación. --- -### 5. Multi-tenancy and Global Management (Super Admin) +### 5. Discussion Forums (Foros de Discusión) +Sistema completo de foros por curso con hilos, respuestas anidadas y moderación. + +#### GET /courses/{id}/discussions +Lista todos los hilos de discusión de un curso. + +- **Filtros Disponibles**: + - `filter=all`: Todos los hilos (por defecto) + - `filter=my_threads`: Solo hilos creados por el usuario + - `filter=unanswered`: Hilos sin respuestas + - `filter=resolved`: Hilos con respuestas marcadas como correctas + - `lesson_id={uuid}`: Filtrar por lección específica +- **Paginación**: `page=1` (50 hilos por página) +- **Respuesta**: Array de `ThreadWithAuthor` con información del autor y estadísticas agregadas. + +```bash +# Listar hilos sin responder +curl "http://localhost:3002/courses/{course_id}/discussions?filter=unanswered" \ + -H "Authorization: Bearer $TOKEN" +``` + +#### POST /courses/{id}/discussions +Crea un nuevo hilo de discusión. + +- **Auto-suscripción**: El autor se suscribe automáticamente para recibir notificaciones. +- **Cuerpo ( CreateThreadPayload ):** + ```json + { + "title": "string", + "content": "string", + "lesson_id": "uuid (opcional)" + } + ``` + +#### GET /discussions/{id} +Obtiene un hilo completo con todas sus respuestas anidadas. + +- **Contador de Vistas**: Incrementa automáticamente el `view_count`. +- **Árbol de Respuestas**: Las respuestas se devuelven en estructura jerárquica con anidación infinita. +- **Respuesta**: Objeto con `thread` y `posts` (árbol de respuestas). + +#### POST /discussions/{id}/posts +Crea una respuesta en un hilo. + +- **Respuestas Anidadas**: Usa `parent_post_id` para responder a un post específico. +- **Validación**: No permite responder si el hilo está bloqueado. +- **Cuerpo ( CreatePostPayload ):** + ```json + { + "content": "string", + "parent_post_id": "uuid (opcional, null para respuesta directa al hilo)" + } + ``` + +#### POST /posts/{id}/vote +Vota por una respuesta (upvote/downvote). + +- **Lógica**: Un usuario solo puede votar una vez por post. Cambiar el voto actualiza el registro existente. +- **Recalculo Automático**: El contador de upvotes se actualiza inmediatamente. +- **Cuerpo ( VotePayload ):** + ```json + { + "vote_type": "upvote" // o "downvote" + } + ``` + +#### POST /posts/{id}/endorse (Solo Instructores) +Marca una respuesta como correcta/aprobada. + +- **Indicador Visual**: Las respuestas endorsadas aparecen primero en la lista. +- **Permiso**: Solo instructores y administradores pueden endorsar. + +#### POST /discussions/{id}/pin (Solo Instructores) +Fija/desfija un hilo en la parte superior de la lista. + +- **Uso**: Para destacar anuncios importantes o FAQs. +- **Permiso**: Solo instructores y administradores. + +#### POST /discussions/{id}/lock (Solo Instructores) +Bloquea/desbloquea un hilo para prevenir nuevas respuestas. + +- **Uso**: Para cerrar discusiones resueltas o inapropiadas. +- **Permiso**: Solo instructores y administradores. + +#### POST /discussions/{id}/subscribe +Suscribe al usuario a las notificaciones del hilo. + +- **Notificaciones**: El usuario recibirá alertas cuando haya nuevas respuestas. + +#### POST /discussions/{id}/unsubscribe +Cancela la suscripción del usuario al hilo. + +```bash +# Crear hilo +curl -X POST "http://localhost:3002/courses/{course_id}/discussions" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"title": "Pregunta sobre Módulo 2", "content": "No entiendo la sección de..."}' + +# Responder a hilo +curl -X POST "http://localhost:3002/discussions/{thread_id}/posts" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"content": "Aquí está mi respuesta..."}' + +# Votar respuesta +curl -X POST "http://localhost:3002/posts/{post_id}/vote" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"vote_type": "upvote"}' +``` + +--- + +### 6. Multi-tenancy and Global Management (Super Admin) OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything. #### Super Admin Definition @@ -442,6 +555,8 @@ Obtiene una lista de todas las organizaciones registradas. - **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers. - **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results. - **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red). +- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions. +- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support. ## 📄 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/e2e/playwright-report/data/37002d9394b727fe022008c37cdd94eac2e9fee1.webm b/e2e/playwright-report/data/37002d9394b727fe022008c37cdd94eac2e9fee1.webm new file mode 100644 index 0000000..ef63965 Binary files /dev/null and b/e2e/playwright-report/data/37002d9394b727fe022008c37cdd94eac2e9fee1.webm differ diff --git a/e2e/playwright-report/data/b04eda85c3a1b09b77ff1ac8fe0d7bc38b571962.png b/e2e/playwright-report/data/b04eda85c3a1b09b77ff1ac8fe0d7bc38b571962.png new file mode 100644 index 0000000..b002061 Binary files /dev/null and b/e2e/playwright-report/data/b04eda85c3a1b09b77ff1ac8fe0d7bc38b571962.png differ diff --git a/e2e/playwright-report/data/f2cdec407447a6a4dfa1c22983b4a515e176988c.webm b/e2e/playwright-report/data/f2cdec407447a6a4dfa1c22983b4a515e176988c.webm new file mode 100644 index 0000000..b35d164 Binary files /dev/null and b/e2e/playwright-report/data/f2cdec407447a6a4dfa1c22983b4a515e176988c.webm differ diff --git a/e2e/playwright-report/data/f7150bc23c771754a4bb815e15cc774b8ec051a3.webm b/e2e/playwright-report/data/f7150bc23c771754a4bb815e15cc774b8ec051a3.webm new file mode 100644 index 0000000..0aa0004 Binary files /dev/null and b/e2e/playwright-report/data/f7150bc23c771754a4bb815e15cc774b8ec051a3.webm differ diff --git a/e2e/playwright-report/index.html b/e2e/playwright-report/index.html index 869981d..6d7f1e0 100644 --- a/e2e/playwright-report/index.html +++ b/e2e/playwright-report/index.html @@ -59,4 +59,4 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho \ No newline at end of file +window.playwrightReportBase64 = "data:application/zip;base64,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"; \ No newline at end of file diff --git a/e2e/tests/student-flow.spec.ts b/e2e/tests/student-flow.spec.ts index 53d4f27..f88a7fa 100644 --- a/e2e/tests/student-flow.spec.ts +++ b/e2e/tests/student-flow.spec.ts @@ -10,13 +10,23 @@ test.describe('Student Flow', () => { console.log(`Starting Student Test for ${email} on ${baseURL}`); // 1. Register - await page.goto('/auth/register'); - await page.fill('input[type="text"][placeholder*="Full Name"], input[placeholder="John Doe"]', name); + await page.goto('/auth/login'); + + // New Flow: Select "Personas" first (if we are on the selection screen) + // Note: /auth/register might still load the main component which defaults to 'selection' view + // The URL logic in the component doesn't automatically switch viewMode based on route yet (it defaults to selection) + // Let's assume we need to click "Personas" + await page.click('button:has-text("Personas")'); + + // Also ensure we are in "Registrarse" mode inside Personal view + await page.click('button:has-text("Registrarse")'); + + await page.fill('input[type="text"][placeholder*="Full Name"], input[placeholder="Juan Pérez"]', name); await page.fill('input[type="email"]', email); await page.fill('input[type="password"]', 'password123'); // Handle optional Organization field if present or skip - await page.click('button:has-text("Comenzar a Aprender")'); + await page.click('button:has-text("Crear Cuenta")'); // 2. View Catalog (Dashboard) await expect(page).toHaveURL('/'); diff --git a/roadmap.md b/roadmap.md index fc4e070..177448b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -169,11 +169,34 @@ - [x] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción. - [x] **Rutas de Aprendizaje**: Recomendaciones basadas en el historial personalizadas y perfiles de habilidades. - [x] **Optimización de Contenedores**: Limpieza automatizada y reducción de huella de infraestructura mediante Build Context optimizado. +- [x] **Split Login Flow**: Separación de flujos de autenticación para Personas y Empresas. + +## Fase 17: Funcionalidades Estilo Open edX (En Progreso) +- [x] **Discussion Forums**: Sistema de foros por curso con hilos, respuestas anidadas y moderación. + - [x] Base de datos (4 tablas: threads, posts, votes, subscriptions) + - [x] Backend API (10 endpoints para gestión completa) + - [x] Permisos diferenciados (estudiante vs instructor) + - [x] Sistema de votación y endorsement + - [ ] Frontend (componentes React) + - [ ] Integración con notificaciones +- [ ] **Course Announcements**: Sistema de anuncios de instructores con notificaciones. +- [ ] **Student Notes**: Anotaciones personales por lección con exportación a PDF. +- [ ] **Peer Assessment**: Evaluación entre pares con rúbricas configurables. +- [ ] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico. +- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones. +- [ ] **Advanced Grading**: Rúbricas detalladas y workflows de calificación. +- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. +- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. +- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares. +- [ ] **Course Preview**: Vista previa de lecciones sin inscripción. +- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes. +- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización. --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica y una **interfaz 100% responsiva**. +**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 y **sistema de foros de discusión funcional (backend completo)**. **Próximas Prioridades**: -1. **Escalado Horizontal**: Orquestación con Kubernetes. -2. **Apps Móviles**: Desarrollo de clientes nativos. +1. **Discussion Forums Frontend**: Componentes React para interfaz de foros. +2. **Course Announcements**: Comunicación instructor-estudiante. +3. **Student Notes**: Anotaciones personales exportables. diff --git a/services/lms-service/migrations/20260126000001_discussion_forums.sql b/services/lms-service/migrations/20260126000001_discussion_forums.sql new file mode 100644 index 0000000..5a3968c --- /dev/null +++ b/services/lms-service/migrations/20260126000001_discussion_forums.sql @@ -0,0 +1,115 @@ +-- Migration: Discussion Forums System +-- Create tables for course discussions, posts, votes, and subscriptions + +-- 1. Discussion Threads Table +CREATE TABLE IF NOT EXISTS discussion_threads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE, -- Optional: thread specific to a lesson + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL, + is_pinned BOOLEAN DEFAULT false, + is_locked BOOLEAN DEFAULT false, + view_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_discussion_threads_course ON discussion_threads(course_id); +CREATE INDEX IF NOT EXISTS idx_discussion_threads_lesson ON discussion_threads(lesson_id); +CREATE INDEX IF NOT EXISTS idx_discussion_threads_author ON discussion_threads(author_id); +CREATE INDEX IF NOT EXISTS idx_discussion_threads_org ON discussion_threads(organization_id); + +-- 2. Discussion Posts Table (Replies to threads) +CREATE TABLE IF NOT EXISTS discussion_posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + thread_id UUID NOT NULL REFERENCES discussion_threads(id) ON DELETE CASCADE, + parent_post_id UUID REFERENCES discussion_posts(id) ON DELETE CASCADE, -- For nested replies + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + upvotes INTEGER DEFAULT 0, + is_endorsed BOOLEAN DEFAULT false, -- Marked by instructor as correct answer + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_discussion_posts_thread ON discussion_posts(thread_id); +CREATE INDEX IF NOT EXISTS idx_discussion_posts_parent ON discussion_posts(parent_post_id); +CREATE INDEX IF NOT EXISTS idx_discussion_posts_author ON discussion_posts(author_id); +CREATE INDEX IF NOT EXISTS idx_discussion_posts_org ON discussion_posts(organization_id); + +-- 3. Discussion Votes Table +CREATE TABLE IF NOT EXISTS discussion_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + post_id UUID NOT NULL REFERENCES discussion_posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote_type VARCHAR(10) NOT NULL CHECK (vote_type IN ('upvote', 'downvote')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(post_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_discussion_votes_post ON discussion_votes(post_id); +CREATE INDEX IF NOT EXISTS idx_discussion_votes_user ON discussion_votes(user_id); +CREATE INDEX IF NOT EXISTS idx_discussion_votes_org ON discussion_votes(organization_id); + +-- 4. Discussion Subscriptions Table +CREATE TABLE IF NOT EXISTS discussion_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + thread_id UUID NOT NULL REFERENCES discussion_threads(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(thread_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_thread ON discussion_subscriptions(thread_id); +CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_user ON discussion_subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_org ON discussion_subscriptions(organization_id); + +-- Trigger to update updated_at on threads +CREATE OR REPLACE FUNCTION update_discussion_thread_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_discussion_thread_timestamp +BEFORE UPDATE ON discussion_threads +FOR EACH ROW +EXECUTE FUNCTION update_discussion_thread_timestamp(); + +-- Trigger to update updated_at on posts +CREATE OR REPLACE FUNCTION update_discussion_post_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_discussion_post_timestamp +BEFORE UPDATE ON discussion_posts +FOR EACH ROW +EXECUTE FUNCTION update_discussion_post_timestamp(); + +-- Function to update thread's updated_at when a new post is added +CREATE OR REPLACE FUNCTION update_thread_on_new_post() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE discussion_threads + SET updated_at = NOW() + WHERE id = NEW.thread_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_thread_on_new_post +AFTER INSERT ON discussion_posts +FOR EACH ROW +EXECUTE FUNCTION update_thread_on_new_post(); diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs new file mode 100644 index 0000000..dbb3d5f --- /dev/null +++ b/services/lms-service/src/handlers_discussions.rs @@ -0,0 +1,458 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use common::models::{ + DiscussionThread, DiscussionPost, DiscussionVote, DiscussionSubscription, + ThreadWithAuthor, PostWithAuthor, +}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +// ========== Request/Response DTOs ========== + +#[derive(Deserialize)] +pub struct CreateThreadPayload { + pub title: String, + pub content: String, + pub lesson_id: Option, +} + +#[derive(Deserialize)] +pub struct CreatePostPayload { + pub content: String, + pub parent_post_id: Option, +} + +#[derive(Deserialize)] +pub struct VotePayload { + pub vote_type: String, // 'upvote' or 'downvote' +} + +#[derive(Deserialize)] +pub struct ThreadListQuery { + pub lesson_id: Option, + pub filter: Option, // 'all', 'my_threads', 'unanswered', 'resolved' + pub page: Option, +} + +// ========== THREAD HANDLERS ========== + +pub async fn list_threads( + Org(org_ctx): Org, + claims: Claims, + Path(course_id): Path, + Query(params): Query, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let page = params.page.unwrap_or(1); + let limit = 50i64; + let offset = (page - 1) * limit; + + let mut query = String::from( + "SELECT + t.*, + u.full_name as author_name, + u.avatar_url as author_avatar, + COUNT(DISTINCT p.id) as post_count, + BOOL_OR(p.is_endorsed) as has_endorsed_answer + 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" + ); + + let mut bind_count = 2; + + if let Some(lesson_id) = params.lesson_id { + bind_count += 1; + query.push_str(&format!(" AND t.lesson_id = ${}", bind_count)); + } + + if let Some(filter) = ¶ms.filter { + match filter.as_str() { + "my_threads" => { + bind_count += 1; + 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)"); + } + "resolved" => { + query.push_str(" AND EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id AND is_endorsed = true)"); + } + _ => {} // 'all' or unknown + } + } + + query.push_str(" GROUP BY t.id, u.full_name, u.avatar_url ORDER BY t.is_pinned DESC, t.updated_at DESC LIMIT $"); + bind_count += 1; + query.push_str(&bind_count.to_string()); + query.push_str(" OFFSET $"); + bind_count += 1; + query.push_str(&bind_count.to_string()); + + let mut sql_query = sqlx::query_as::<_, ThreadWithAuthor>(&query) + .bind(course_id) + .bind(org_ctx.id); + + if let Some(lesson_id) = params.lesson_id { + sql_query = sql_query.bind(lesson_id); + } + + if let Some(filter) = ¶ms.filter { + if filter == "my_threads" { + sql_query = sql_query.bind(claims.sub); + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let threads = sql_query + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(threads)) +} + +pub async fn create_thread( + Org(org_ctx): Org, + claims: Claims, + Path(course_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let thread = sqlx::query_as::<_, DiscussionThread>( + "INSERT INTO discussion_threads (organization_id, course_id, lesson_id, author_id, title, content) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *" + ) + .bind(org_ctx.id) + .bind(course_id) + .bind(payload.lesson_id) + .bind(claims.sub) + .bind(payload.title) + .bind(payload.content) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Auto-subscribe author to thread + let _ = sqlx::query( + "INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING" + ) + .bind(org_ctx.id) + .bind(thread.id) + .bind(claims.sub) + .execute(&pool) + .await; + + Ok(Json(thread)) +} + +pub async fn get_thread_detail( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, +) -> Result, (StatusCode, String)> { + // Increment view count + let _ = sqlx::query("UPDATE discussion_threads SET view_count = view_count + 1 WHERE id = $1") + .bind(thread_id) + .execute(&pool) + .await; + + // Get thread with author info + let thread = sqlx::query_as::<_, ThreadWithAuthor>( + "SELECT + t.*, + u.full_name as author_name, + u.avatar_url as author_avatar, + COUNT(DISTINCT p.id) as post_count, + BOOL_OR(p.is_endorsed) as has_endorsed_answer + 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.id = $1 AND t.organization_id = $2 + GROUP BY t.id, u.full_name, u.avatar_url" + ) + .bind(thread_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + + // Get all posts with author info and user votes + let posts = get_thread_posts_recursive(&pool, thread_id, None, claims.sub, org_ctx.id).await?; + + Ok(Json(serde_json::json!({ + "thread": thread, + "posts": posts + }))) +} + +// Recursive function to build nested post tree +fn get_thread_posts_recursive<'a>( + pool: &'a PgPool, + thread_id: Uuid, + parent_id: Option, + user_id: Uuid, + org_id: Uuid, +) -> std::pin::Pin, (StatusCode, String)>> + Send + 'a>> { + Box::pin(async move { + let parent_filter = match parent_id { + Some(_) => "parent_post_id = $2", + None => "parent_post_id IS NULL", + }; + + let query = format!( + "SELECT + p.*, + u.full_name as author_name, + u.avatar_url as author_avatar, + v.vote_type as user_vote + FROM discussion_posts p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN discussion_votes v ON p.id = v.post_id AND v.user_id = $3 + WHERE p.thread_id = $1 AND p.organization_id = $4 AND {} + ORDER BY p.is_endorsed DESC, p.upvotes DESC, p.created_at ASC", + parent_filter + ); + + let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query) + .bind(thread_id); + + if let Some(pid) = parent_id { + sql_query = sql_query.bind(pid); + } + + sql_query = sql_query.bind(user_id).bind(org_id); + + let mut posts = sql_query + .fetch_all(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 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?; + } + + Ok(posts) + }) +} + +pub async fn pin_thread( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, +) -> Result { + // Check if user is instructor + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .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())); + } + + sqlx::query("UPDATE discussion_threads SET is_pinned = NOT is_pinned WHERE id = $1 AND organization_id = $2") + .bind(thread_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +pub async fn lock_thread( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, +) -> Result { + // Check if user is instructor + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .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())); + } + + sqlx::query("UPDATE discussion_threads SET is_locked = NOT is_locked WHERE id = $1 AND organization_id = $2") + .bind(thread_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +// ========== POST HANDLERS ========== + +pub async fn create_post( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, + 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()))?; + + if thread.0 { + return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string())); + } + + let post = sqlx::query_as::<_, DiscussionPost>( + "INSERT INTO discussion_posts (organization_id, thread_id, parent_post_id, author_id, content) + VALUES ($1, $2, $3, $4, $5) + RETURNING *" + ) + .bind(org_ctx.id) + .bind(thread_id) + .bind(payload.parent_post_id) + .bind(claims.sub) + .bind(payload.content) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // TODO: Send notifications to subscribed users + + Ok(Json(post)) +} + +pub async fn endorse_post( + Org(org_ctx): Org, + claims: Claims, + Path(post_id): Path, + State(pool): State, +) -> Result { + // Check if user is instructor + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .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())); + } + + sqlx::query("UPDATE discussion_posts SET is_endorsed = NOT is_endorsed WHERE id = $1 AND organization_id = $2") + .bind(post_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +pub async fn vote_post( + Org(org_ctx): Org, + claims: Claims, + Path(post_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result { + if payload.vote_type != "upvote" && payload.vote_type != "downvote" { + return Err((StatusCode::BAD_REQUEST, "Invalid vote type".to_string())); + } + + // Upsert vote + sqlx::query( + "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" + ) + .bind(org_ctx.id) + .bind(post_id) + .bind(claims.sub) + .bind(&payload.vote_type) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Recalculate upvotes + let upvote_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'" + ) + .bind(post_id) + .fetch_one(&pool) + .await + .unwrap_or(0); + + sqlx::query("UPDATE discussion_posts SET upvotes = $1 WHERE id = $2") + .bind(upvote_count as i32) + .bind(post_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +// ========== SUBSCRIPTION HANDLERS ========== + +pub async fn subscribe_thread( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, +) -> Result { + sqlx::query( + "INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING" + ) + .bind(org_ctx.id) + .bind(thread_id) + .bind(claims.sub) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +pub async fn unsubscribe_thread( + Org(org_ctx): Org, + claims: Claims, + Path(thread_id): Path, + State(pool): State, +) -> Result { + sqlx::query( + "DELETE FROM discussion_subscriptions + WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3" + ) + .bind(thread_id) + .bind(claims.sub) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 8b934e9..5044232 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,5 +1,6 @@ mod db_util; mod handlers; +mod handlers_discussions; use axum::{ Router, middleware, @@ -88,6 +89,17 @@ async fn main() { "/notifications/{id}/read", post(handlers::mark_notification_as_read), ) + // Discussion Forums Routes + .route("/courses/{id}/discussions", get(handlers_discussions::list_threads)) + .route("/courses/{id}/discussions", post(handlers_discussions::create_thread)) + .route("/discussions/{id}", get(handlers_discussions::get_thread_detail)) + .route("/discussions/{id}/pin", post(handlers_discussions::pin_thread)) + .route("/discussions/{id}/lock", post(handlers_discussions::lock_thread)) + .route("/discussions/{id}/posts", post(handlers_discussions::create_post)) + .route("/posts/{id}/endorse", post(handlers_discussions::endorse_post)) + .route("/posts/{id}/vote", post(handlers_discussions::vote_post)) + .route("/discussions/{id}/subscribe", post(handlers_discussions::subscribe_thread)) + .route("/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread)) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index b21101b..9d0ad00 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -291,6 +291,104 @@ pub struct RecommendationResponse { pub recommendations: Vec, } +// Discussion Forums Models +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct DiscussionThread { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Option, + pub author_id: Uuid, + pub title: String, + pub content: String, + pub is_pinned: bool, + pub is_locked: bool, + pub view_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct DiscussionPost { + pub id: Uuid, + pub organization_id: Uuid, + pub thread_id: Uuid, + pub parent_post_id: Option, + pub author_id: Uuid, + pub content: String, + pub upvotes: i32, + pub is_endorsed: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct DiscussionVote { + pub id: Uuid, + pub organization_id: Uuid, + pub post_id: Uuid, + pub user_id: Uuid, + pub vote_type: String, // 'upvote' or 'downvote' + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct DiscussionSubscription { + pub id: Uuid, + pub organization_id: Uuid, + pub thread_id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, +} + +// Response DTOs for Discussion APIs +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct ThreadWithAuthor { + // Thread fields + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Option, + pub author_id: Uuid, + pub title: String, + pub content: String, + pub is_pinned: bool, + pub is_locked: bool, + pub view_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + // Author info + pub author_name: String, + pub author_avatar: Option, + // Aggregated data + pub post_count: i64, + pub has_endorsed_answer: bool, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct PostWithAuthor { + // Post fields + pub id: Uuid, + pub organization_id: Uuid, + pub thread_id: Uuid, + pub parent_post_id: Option, + pub author_id: Uuid, + pub content: String, + pub upvotes: i32, + pub is_endorsed: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // Author info + pub author_name: String, + pub author_avatar: Option, + // User interaction + pub user_vote: Option, // 'upvote', 'downvote', or null + // Nested replies (not from DB, populated manually) + #[sqlx(skip)] + pub replies: Vec, +} + + #[cfg(test)] mod tests { use super::*; diff --git a/web/experience/package-lock.json b/web/experience/package-lock.json index bee89bd..f1892eb 100644 --- a/web/experience/package-lock.json +++ b/web/experience/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^11.2.10", "lucide-react": "^0.395.0", "next": "14.2.21", @@ -1872,6 +1873,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/web/experience/package.json b/web/experience/package.json index 846cc7f..5664f55 100644 --- a/web/experience/package.json +++ b/web/experience/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^11.2.10", "lucide-react": "^0.395.0", "next": "14.2.21", diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index 784e325..b5134da 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -4,20 +4,28 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { lmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; -import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react"; +import { GraduationCap, Lock, Mail, User, Building2, ChevronLeft, ArrowRight } from "lucide-react"; + +type ViewMode = 'selection' | 'personal' | 'enterprise'; export default function ExperienceLoginPage() { const router = useRouter(); const { login } = useAuth(); - const [isLogin, setIsLogin] = useState(true); + + // State + const [viewMode, setViewMode] = useState('selection'); + const [isLogin, setIsLogin] = useState(true); // For Personal flow + + // Form Inputs const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [organizationName, setOrganizationName] = useState(""); const [fullName, setFullName] = useState(""); + const [orgIdForSSO, setOrgIdForSSO] = useState(""); + + // UI State const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const [ssoMode, setSSOMode] = useState(false); - const [orgIdForSSO, setOrgIdForSSO] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -25,227 +33,207 @@ export default function ExperienceLoginPage() { setLoading(true); try { - if (isLogin) { - const response = await lmsApi.login({ email, password }); - - // Verify user is a student - if (response.user.role !== "student") { - setError("Acceso denegado. Este portal es solo para estudiantes. Utiliza el portal de Studio para instructores."); - setLoading(false); - return; + if (viewMode === 'personal') { + if (isLogin) { + const response = await lmsApi.login({ email, password }); + if (response.user.role !== "student") { + throw new Error("Acceso denegado. Este portal es solo para estudiantes."); + } + login(response.user, response.token); + router.push("/"); + } else { + const response = await lmsApi.register({ + email, + password, + full_name: fullName, + organization_name: organizationName, + }); + login(response.user, response.token); + router.push("/"); } - - login(response.user, response.token); - router.push("/"); - } else { - const response = await lmsApi.register({ - email, - password, - full_name: fullName, - organization_name: organizationName, - }); - - login(response.user, response.token); - router.push("/"); + } else if (viewMode === 'enterprise') { + if (!orgIdForSSO) { + throw new Error("El ID de la organización es requerido"); + } + lmsApi.initSSOLogin(orgIdForSSO); } } catch (err) { setError(err instanceof Error ? err.message : "Falló la autenticación"); - } finally { setLoading(false); } }; + const handleBack = () => { + setError(""); + setViewMode('selection'); + }; + return (
-
+
{/* Header */}
-
+
-

Experiencia OpenCCB

- Portal de Aprendizaje para Estudiantes +

Experiencia OpenCCB

+

Portal de Aprendizaje para Estudiantes

- {/* Login/Register Form */} -
-
- - -
+ {/* Main Content Card */} +
-
- {!ssoMode ? ( - <> + {/* View: SELECTION */} + {viewMode === 'selection' && ( +
+

¿Cómo deseas ingresar?

+ + + + +
+ )} + + {/* View: PERSONAL (Email/Pass) */} + {viewMode === 'personal' && ( +
+ + +
+ + +
+ + {!isLogin && ( <> -
- +
+
- - setFullName(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="Jane Smith" - required - /> + + setFullName(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="Juan Pérez" />
-
- -
- - setOrganizationName(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="Tu Escuela o Empresa" - /> -
-

Si se deja en blanco, se creará una organización basada en el dominio de tu correo electrónico.

-
)} -
- +
+
- - setEmail(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="estudiante@ejemplo.com" - required - /> + + setEmail(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="nombre@correo.com" />
-
- +
+
- - setPassword(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="••••••••" - required - /> + + setPassword(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="••••••••" />
- - ) : ( -
- -
- - setOrgIdForSSO(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="00000000-0000-0000-0000-000000000000" - required - /> -
-

- Contacta a tu administrador si no conoces el ID de tu organización. -

-
- )} - {error && ( -
- {error} -
- )} + {error &&
{error}
} - - -
-
- -
-
- O bien -
+ +
+ )} - - + {/* View: ENTERPRISE (Domain Login) */} + {viewMode === 'enterprise' && ( +
+ -
-

- ¿Eres un instructor?{" "} - - Ir al Portal de Instructores - -

-
+
+
+ +
+

Acceso Corporativo

+

Ingresa las credenciales de tu empresa

+
+ +
+
+ +
+ + setOrganizationName(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors font-mono" placeholder="acme-corp" /> +
+
+ +
+ +
+ + setEmail(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="usuario@empresa.com" /> +
+
+ +
+ +
+ + setPassword(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="••••••••" /> +
+
+ + {error &&
{error}
} + + +
+
+ )}
-

- Experiencia OpenCCB - Portal de Aprendizaje para Estudiantes -

+ {/* Footer */} +
+

+ ¿Eres instructor? Ir al Portal de Instructores +

+
); diff --git a/web/experience/src/app/auth/register/page.tsx b/web/experience/src/app/auth/register/page.tsx index 930df16..dcb718a 100644 --- a/web/experience/src/app/auth/register/page.tsx +++ b/web/experience/src/app/auth/register/page.tsx @@ -1,132 +1,18 @@ "use client"; -import { useState } from "react"; -import { lmsApi } from "@/lib/api"; -import { useAuth } from "@/context/AuthContext"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react"; export default function RegisterPage() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [fullName, setFullName] = useState(""); - const [organizationName, setOrganizationName] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - const { login } = useAuth(); const router = useRouter(); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(""); - try { - const res = await lmsApi.register({ email, password, full_name: fullName, organization_name: organizationName }); - login(res.user, res.token); - router.push("/"); - } catch (err) { - const message = err instanceof Error ? err.message : "El registro falló. Por favor, inténtalo de nuevo."; - setError(message); - } finally { - setLoading(false); - } - }; + useEffect(() => { + router.replace("/auth/login"); + }, [router]); return ( -
-
-
-
- -
-

Crear Cuenta

-

Únete a la próxima generación de aprendices

-
- -
-
- {error && ( -
- {error} -
- )} - -
- -
- - setFullName(e.target.value)} - placeholder="John Doe" - className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all" - /> -
-
- -
- -
- - setEmail(e.target.value)} - placeholder="name@company.com" - className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all" - /> -
-
- -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" - className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all" - /> -
-
- -
- -
- - setOrganizationName(e.target.value)} - placeholder="Tu Escuela o Empresa" - className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all" - /> -
-

Si está en blanco, usaremos el dominio de tu correo electrónico.

-
- - -
-
- -

- ¿Ya tienes una cuenta? Inicia sesión aquí -

-
+
+
Redirecting to login...
); } diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index ef0b468..45c131e 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -6,6 +6,7 @@ import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } fr import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; +import DiscussionBoard from "@/components/DiscussionBoard"; export default function CourseOutlinePage({ params }: { params: { id: string } }) { const { user } = useAuth(); @@ -250,6 +251,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
))}
+ + {/* Discussions Section */} +
+ +
); } diff --git a/web/experience/src/components/DiscussionBoard.tsx b/web/experience/src/components/DiscussionBoard.tsx new file mode 100644 index 0000000..c9ebe89 --- /dev/null +++ b/web/experience/src/components/DiscussionBoard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { lmsApi, ThreadWithAuthor } from "@/lib/api"; +import ThreadList from "./ThreadList"; +import ThreadDetail from "./ThreadDetail"; +import NewThreadModal from "./NewThreadModal"; +import { MessageSquarePlus, Filter } from "lucide-react"; + +interface DiscussionBoardProps { + courseId: string; + lessonId?: string; +} + +type FilterType = 'all' | 'my_threads' | 'unanswered' | 'resolved'; + +export default function DiscussionBoard({ courseId, lessonId }: DiscussionBoardProps) { + const [threads, setThreads] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + const [page, setPage] = useState(1); + const [selectedThreadId, setSelectedThreadId] = useState(null); + const [showNewThreadModal, setShowNewThreadModal] = useState(false); + + useEffect(() => { + loadThreads(); + }, [courseId, filter, page, lessonId]); + + const loadThreads = async () => { + setLoading(true); + try { + const data = await lmsApi.getDiscussions(courseId, filter, lessonId, page); + setThreads(data); + } catch (error) { + console.error("Error loading discussions:", error); + } finally { + setLoading(false); + } + }; + + const handleThreadClick = (threadId: string) => { + setSelectedThreadId(threadId); + }; + + const handleBack = () => { + setSelectedThreadId(null); + loadThreads(); // Reload list in case of changes + }; + + const handleNewThreadSuccess = () => { + setShowNewThreadModal(false); + loadThreads(); + }; + + // If viewing a specific thread + if (selectedThreadId) { + return ; + } + + return ( +
+ {/* Header */} +
+

Discusiones

+
+ +
+ + {/* Filters */} +
+
+ + Filtrar: +
+ + {[ + { value: 'all', label: 'Todos' }, + { value: 'my_threads', label: 'Mis Hilos' }, + { value: 'unanswered', label: 'Sin Responder' }, + { value: 'resolved', label: 'Resueltos' } + ].map((filterOption) => ( + + ))} +
+ + {/* Thread List */} + {loading ? ( +
+
+
+ ) : ( + + )} + + {/* Pagination */} + {threads.length === 50 && ( +
+ {page > 1 && ( + + )} + Página {page} + +
+ )} + + {/* New Thread Modal */} + {showNewThreadModal && ( + setShowNewThreadModal(false)} + onSuccess={handleNewThreadSuccess} + /> + )} +
+ ); +} diff --git a/web/experience/src/components/NewThreadModal.tsx b/web/experience/src/components/NewThreadModal.tsx new file mode 100644 index 0000000..71e3971 --- /dev/null +++ b/web/experience/src/components/NewThreadModal.tsx @@ -0,0 +1,125 @@ +"use client"; + +import React, { useState } from "react"; +import { lmsApi, CreateThreadPayload } from "@/lib/api"; +import { X, Send } from "lucide-react"; + +interface NewThreadModalProps { + courseId: string; + lessonId?: string; + onClose: () => void; + onSuccess: () => void; +} + +export default function NewThreadModal({ courseId, lessonId, onClose, onSuccess }: NewThreadModalProps) { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || !content.trim()) { + setError("El título y el contenido son requeridos"); + return; + } + + setSubmitting(true); + setError(""); + + try { + const payload: CreateThreadPayload = { + title: title.trim(), + content: content.trim(), + lesson_id: lessonId + }; + + await lmsApi.createThread(courseId, payload); + onSuccess(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al crear el hilo"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

Nuevo Hilo de Discusión

+ +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="¿Cuál es tu pregunta o tema?" + className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" + disabled={submitting} + maxLength={200} + /> +

{title.length}/200 caracteres

+
+ + {/* Content */} +
+ +