diff --git a/README.md b/README.md index 43cbb26..89bdc26 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **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. - **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced). -- **Cohorts & Groups**: Segmentación de estudiantes en cohortes para gestión de grupos y análisis comparativo. -- **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes y exportación a CSV. +- **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes, exportación masiva a CSV con desgloses por categoría y pertenencia a cohortes. +- **Bulk Operations**: Herramientas administrativas para inscripción masiva de usuarios vía email y comunicación segmentada. +- **Segmented Announcements**: Sistema de anuncios con capacidad de dirigirse a cohortes específicas y notificaciones filtradas. - **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos. - **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio. - **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS. diff --git a/roadmap.md b/roadmap.md index 02df9a3..c9c287b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -186,7 +186,7 @@ - [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones. - [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación. - [x] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. -- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. +- [x] **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. @@ -215,9 +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**, **gestión de anuncios**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado** y **Secuencias de Aprendizaje (Prerrequisitos)**. +**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 segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado** y **Secuencias de Aprendizaje**. **Próximas Prioridades**: -1. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos. -2. **Course Teams**: Gestión de múltiples instructores por curso. -3. **Course Preview**: Vista previa de lecciones sin inscripción. +1. **Course Teams**: Gestión de múltiples instructores por curso con roles granulares. +2. **Course Preview**: Vista previa de lecciones sin inscripción para mejorar la conversión. +3. **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización. diff --git a/services/cms-service/migrations/20260219000001_course_teams.sql b/services/cms-service/migrations/20260219000001_course_teams.sql new file mode 100644 index 0000000..a813cd7 --- /dev/null +++ b/services/cms-service/migrations/20260219000001_course_teams.sql @@ -0,0 +1,17 @@ +-- Migration to support multiple instructors per course +CREATE TABLE course_instructors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'instructor', -- 'primary', 'instructor', 'assistant' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(course_id, user_id) +); + +-- Seed with existing data from courses table +INSERT INTO course_instructors (course_id, user_id, role) +SELECT id, instructor_id, 'primary' FROM courses; + +-- We keep the instructor_id in courses for now to avoid breaking changes, +-- but it should be considered deprecated in favor of course_instructors. +COMMENT ON COLUMN courses.instructor_id IS 'Deprecated: use course_instructors table instead.'; diff --git a/services/lms-service/migrations/20260218000001_announcement_segmentation.sql b/services/lms-service/migrations/20260218000001_announcement_segmentation.sql new file mode 100644 index 0000000..655a8ff --- /dev/null +++ b/services/lms-service/migrations/20260218000001_announcement_segmentation.sql @@ -0,0 +1,9 @@ +-- Add table for announcement-cohort relationship +CREATE TABLE IF NOT EXISTS announcement_cohorts ( + announcement_id UUID NOT NULL REFERENCES course_announcements(id) ON DELETE CASCADE, + cohort_id UUID NOT NULL REFERENCES cohorts(id) ON DELETE CASCADE, + PRIMARY KEY (announcement_id, cohort_id) +); + +-- Index for performance +CREATE INDEX idx_announcement_cohorts_cohort ON announcement_cohorts(cohort_id); diff --git a/services/lms-service/src/handlers_announcements.rs b/services/lms-service/src/handlers_announcements.rs index ac20571..c603829 100644 --- a/services/lms-service/src/handlers_announcements.rs +++ b/services/lms-service/src/handlers_announcements.rs @@ -5,7 +5,7 @@ use axum::{ }; use common::auth::Claims; use common::middleware::Org; -use common::models::{CourseAnnouncement, AnnouncementWithAuthor}; +use common::models::{AnnouncementWithAuthor, CourseAnnouncement}; use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; @@ -17,6 +17,7 @@ pub struct CreateAnnouncementPayload { pub title: String, pub content: String, pub is_pinned: Option, + pub cohort_ids: Option>, } #[derive(Deserialize)] @@ -33,7 +34,7 @@ pub async fn list_announcements( Path(course_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { - let announcements = sqlx::query_as::<_, AnnouncementWithAuthor>( + let mut announcements = sqlx::query_as::<_, AnnouncementWithAuthor>( "SELECT a.*, u.full_name as author_name, @@ -41,7 +42,7 @@ pub async fn list_announcements( FROM course_announcements a LEFT JOIN users u ON a.author_id = u.id WHERE a.course_id = $1 AND a.organization_id = $2 - ORDER BY a.is_pinned DESC, a.created_at DESC" + ORDER BY a.is_pinned DESC, a.created_at DESC", ) .bind(course_id) .bind(org_ctx.id) @@ -49,6 +50,21 @@ pub async fn list_announcements( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Attach cohort_ids to each announcement + for a in &mut announcements { + let cohorts = sqlx::query!( + "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1", + a.id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !cohorts.is_empty() { + a.cohort_ids = Some(cohorts.into_iter().map(|c| c.cohort_id).collect()); + } + } + Ok(Json(announcements)) } @@ -67,11 +83,19 @@ pub async fn create_announcement( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can create announcements".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can create announcements".to_string(), + )); } - // Create announcement - let announcement = sqlx::query_as::<_, CourseAnnouncement>( + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 1. Create announcement + let mut announcement = sqlx::query_as::<_, CourseAnnouncement>( "INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *" @@ -82,18 +106,63 @@ pub async fn create_announcement( .bind(&payload.title) .bind(&payload.content) .bind(payload.is_pinned.unwrap_or(false)) - .fetch_one(&pool) + .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Get all enrolled students for notifications - let enrolled_students = sqlx::query_as::<_, (Uuid,)>( - "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2" - ) - .bind(course_id) - .bind(claims.sub) // Exclude the announcement author - .fetch_all(&pool) - .await + // 2. Link cohorts if provided + if let Some(ref cohort_ids) = payload.cohort_ids { + for cohort_id in cohort_ids { + sqlx::query( + "INSERT INTO announcement_cohorts (announcement_id, cohort_id) VALUES ($1, $2)", + ) + .bind(announcement.id) + .bind(cohort_id) + .execute(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + announcement.cohort_ids = Some(cohort_ids.clone()); + } + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 3. Get target students for notifications + let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids { + if !cohort_ids.is_empty() { + sqlx::query_as::<_, (Uuid,)>( + "SELECT DISTINCT uc.user_id + FROM user_cohorts uc + JOIN enrollments e ON uc.user_id = e.user_id AND e.course_id = $1 + WHERE uc.cohort_id = ANY($2) AND uc.user_id != $3", + ) + .bind(course_id) + .bind(cohort_ids) + .bind(claims.sub) + .fetch_all(&pool) + .await + } else { + // Fallback to everyone if empty list provided (though UI should prevent) + sqlx::query_as::<_, (Uuid,)>( + "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", + ) + .bind(course_id) + .bind(claims.sub) + .fetch_all(&pool) + .await + } + } else { + // No segment provided -> everyone in the course + sqlx::query_as::<_, (Uuid,)>( + "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", + ) + .bind(course_id) + .bind(claims.sub) + .fetch_all(&pool) + .await + } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Create notification for each enrolled student @@ -137,12 +206,15 @@ pub async fn update_announcement( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can update announcements".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can update announcements".to_string(), + )); } // Get current announcement to verify ownership let current = sqlx::query_as::<_, CourseAnnouncement>( - "SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2" + "SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2", ) .bind(announcement_id) .bind(org_ctx.id) @@ -158,7 +230,7 @@ pub async fn update_announcement( "UPDATE course_announcements SET title = $1, content = $2, is_pinned = $3 WHERE id = $4 AND organization_id = $5 - RETURNING *" + RETURNING *", ) .bind(title) .bind(content) @@ -186,12 +258,15 @@ pub async fn delete_announcement( .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { - return Err((StatusCode::FORBIDDEN, "Only instructors can delete announcements".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only instructors can delete announcements".to_string(), + )); } sqlx::query( "DELETE FROM course_announcements - WHERE id = $1 AND organization_id = $2" + WHERE id = $1 AND organization_id = $2", ) .bind(announcement_id) .bind(org_ctx.id) diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 6948cf8..6934801 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -418,6 +418,8 @@ pub struct CourseAnnouncement { pub is_pinned: bool, pub created_at: DateTime, pub updated_at: DateTime, + #[sqlx(skip)] + pub cohort_ids: Option>, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] @@ -435,6 +437,8 @@ pub struct AnnouncementWithAuthor { // Author info pub author_name: String, pub author_avatar: Option, + #[sqlx(skip)] + pub cohort_ids: Option>, } // Student Notes diff --git a/web/studio/src/app/courses/[id]/announcements/page.tsx b/web/studio/src/app/courses/[id]/announcements/page.tsx new file mode 100644 index 0000000..c087ec2 --- /dev/null +++ b/web/studio/src/app/courses/[id]/announcements/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { lmsApi, AnnouncementWithAuthor, Cohort } from "@/lib/api"; +import { Megaphone, Plus, Search, Loader2, ArrowLeft, Pin, Trash2, Users } from "lucide-react"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; +import { formatDistanceToNow } from "date-fns"; +import { es } from "date-fns/locale"; + +export default function AnnouncementsPage() { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const [announcements, setAnnouncements] = useState([]); + const [cohorts, setCohorts] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [showNewModal, setShowNewModal] = useState(false); + + const fetchData = async () => { + try { + setLoading(true); + const [annData, cohortData] = await Promise.all([ + lmsApi.listAnnouncements(id), + lmsApi.getCohorts() + ]); + setAnnouncements(annData); + setCohorts(cohortData.filter(c => c.course_id === id)); + } catch (error) { + console.error("Error fetching announcements:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [id]); + + const handleDelete = async (announcementId: string) => { + if (!confirm("¿Estás seguro de que deseas eliminar este anuncio?")) return; + try { + await lmsApi.deleteAnnouncement(announcementId); + fetchData(); + } catch (error) { + console.error("Error deleting announcement:", error); + alert("Error al eliminar el anuncio"); + } + }; + + const filteredAnnouncements = announcements.filter(a => + a.title.toLowerCase().includes(searchTerm.toLowerCase()) || + a.content.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Announcements +

+

Manage course communications and cohort segments

+
+
+ +
+ + +
+ {/* Search Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full bg-black/20 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-orange-500/50 transition-all" + /> +
+
+ + {/* Announcements List */} + {loading ? ( +
+ +

Loading announcements...

+
+ ) : filteredAnnouncements.length > 0 ? ( +
+ {filteredAnnouncements.map((a) => ( +
+
+
+
+ {a.author_avatar ? ( + {a.author_name} + ) : ( + a.author_name.charAt(0) + )} +
+
+

{a.author_name}

+
+ {formatDistanceToNow(new Date(a.created_at), { addSuffix: true, locale: es })} + {a.cohort_ids && a.cohort_ids.length > 0 && ( + <> + +
+ + {a.cohort_ids.length} Cohorts +
+ + )} +
+
+
+
+ {a.is_pinned && } + +
+
+

{a.title}

+

{a.content}

+ + {/* Display Target Cohort Names if segmented */} + {a.cohort_ids && a.cohort_ids.length > 0 && ( +
+ {a.cohort_ids.map(cid => { + const cohort = cohorts.find(c => c.id === cid); + return ( + + {cohort?.name || 'Unknown Cohort'} + + ); + })} +
+ )} +
+ ))} +
+ ) : ( +
+ +

No announcements found

+

Start by creating a new announcement for your students.

+
+ )} +
+
+
+ + {showNewModal && ( + setShowNewModal(false)} + onSuccess={() => { + setShowNewModal(false); + fetchData(); + }} + /> + )} +
+ ); +} + +// Inline NewAnnouncementModal for simplicity, or move to its own file if it grows +function NewAnnouncementModal({ courseId, cohorts, onClose, onSuccess }: { courseId: string, cohorts: Cohort[], onClose: () => void, onSuccess: () => void }) { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isPinned, setIsPinned] = useState(false); + const [selectedCohorts, setSelectedCohorts] = useState([]); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await lmsApi.createAnnouncement(courseId, { + title, + content, + is_pinned: isPinned, + cohort_ids: selectedCohorts.length > 0 ? selectedCohorts : undefined + }); + onSuccess(); + } catch (err) { + console.error(err); + alert("Failed to create announcement"); + } finally { + setLoading(false); + } + }; + + const toggleCohort = (id: string) => { + setSelectedCohorts(prev => + prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id] + ); + }; + + return ( +
+
+
+

+ + Create New Announcement +

+ +
+
+
+ + setTitle(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50" + placeholder="Announcement title" + /> +
+
+ +