diff --git a/README.md b/README.md index 3ba35d3..41abdea 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **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. +- **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced). ## Requisitos del Sistema @@ -118,6 +119,13 @@ npm install npm run dev ``` +#### 🧹 Mantenimiento de Base de Datos +Para resetear completamente el entorno de desarrollo y empezar desde cero: +```bash +# Borra las bases de datos openccb_cms/lms y las vuelve a migrar +./scripts/reset_db.sh +``` + ## 🔌 Manual del Desarrollador (API) ### 1. Autenticación y Cuentas @@ -583,6 +591,7 @@ Obtiene una lista de todas las organizaciones registradas. - **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. +- **Student Notes Panel**: Personal lesson annotations with glassmorphism UI and intelligent auto-save. ## 📄 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/scripts/reset_db.sh b/reset_db.sh similarity index 100% rename from scripts/reset_db.sh rename to reset_db.sh diff --git a/roadmap.md b/roadmap.md index a04170c..dbd492d 100644 --- a/roadmap.md +++ b/roadmap.md @@ -180,7 +180,7 @@ - [x] Frontend (componentes React) - [x] Integración con notificaciones - [x] **Course Announcements**: Sistema de anuncios de instructores con notificaciones. -- [ ] **Student Notes**: Anotaciones personales por lección con exportación a PDF. +- [x] **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. diff --git a/services/lms-service/migrations/20260216000002_cohorts.sql b/services/lms-service/migrations/20260216000002_cohorts.sql new file mode 100644 index 0000000..43b6601 --- /dev/null +++ b/services/lms-service/migrations/20260216000002_cohorts.sql @@ -0,0 +1,26 @@ +-- Cohorts table +CREATE TABLE IF NOT EXISTS cohorts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT cohorts_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +); + +-- User-Cohort relationship table (M:N) +CREATE TABLE IF NOT EXISTS user_cohorts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cohort_id UUID NOT NULL, + user_id UUID NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT user_cohorts_cohort_id_fkey FOREIGN KEY (cohort_id) REFERENCES cohorts(id) ON DELETE CASCADE, + CONSTRAINT user_cohorts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT user_cohorts_unique UNIQUE (cohort_id, user_id) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_cohorts_organization_id ON cohorts(organization_id); +CREATE INDEX IF NOT EXISTS idx_user_cohorts_cohort_id ON user_cohorts(cohort_id); +CREATE INDEX IF NOT EXISTS idx_user_cohorts_user_id ON user_cohorts(user_id); diff --git a/services/lms-service/src/handlers_cohorts.rs b/services/lms-service/src/handlers_cohorts.rs new file mode 100644 index 0000000..2e188b8 --- /dev/null +++ b/services/lms-service/src/handlers_cohorts.rs @@ -0,0 +1,146 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use common::models::{AddMemberPayload, Cohort, CreateCohortPayload, UserCohort}; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn list_cohorts( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let cohorts = sqlx::query_as::<_, Cohort>( + "SELECT * FROM cohorts WHERE organization_id = $1 ORDER BY created_at DESC", + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(cohorts)) +} + +pub async fn create_cohort( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let cohort = sqlx::query_as::<_, Cohort>( + r#" + INSERT INTO cohorts (organization_id, name, description) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(org_ctx.id) + .bind(payload.name) + .bind(payload.description) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(cohort)) +} + +pub async fn add_cohort_member( + Org(org_ctx): Org, + _claims: Claims, + Path(cohort_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Verify cohort belongs to org + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", + ) + .bind(cohort_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !exists { + return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + } + + let member = sqlx::query_as::<_, UserCohort>( + r#" + INSERT INTO user_cohorts (cohort_id, user_id) + VALUES ($1, $2) + ON CONFLICT (cohort_id, user_id) DO UPDATE SET assigned_at = NOW() + RETURNING * + "#, + ) + .bind(cohort_id) + .bind(payload.user_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(member)) +} + +pub async fn remove_cohort_member( + Org(org_ctx): Org, + _claims: Claims, + Path((cohort_id, user_id)): Path<(Uuid, Uuid)>, + State(pool): State, +) -> Result { + // Verify cohort belongs to org + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", + ) + .bind(cohort_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !exists { + return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + } + + sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2") + .bind(cohort_id) + .bind(user_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_cohort_members( + Org(org_ctx): Org, + _claims: Claims, + Path(cohort_id): Path, + State(pool): State, +) -> Result>, (StatusCode, String)> { + // Verify cohort belongs to org + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)", + ) + .bind(cohort_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !exists { + return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string())); + } + + let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1") + .bind(cohort_id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(members)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index efabe48..1ff13a6 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,6 +1,7 @@ mod db_util; mod handlers; mod handlers_announcements; +mod handlers_cohorts; mod handlers_discussions; mod handlers_notes; mod handlers_payments; @@ -150,6 +151,21 @@ async fn main() { ) .route("/lessons/{id}/notes", get(handlers_notes::get_note)) .route("/lessons/{id}/notes", put(handlers_notes::save_note)) + // Cohorts + .route("/cohorts", get(handlers_cohorts::list_cohorts)) + .route("/cohorts", post(handlers_cohorts::create_cohort)) + .route( + "/cohorts/{id}/members", + post(handlers_cohorts::add_cohort_member), + ) + .route( + "/cohorts/{cohort_id}/members/{user_id}", + delete(handlers_cohorts::remove_cohort_member), + ) + .route( + "/cohorts/{id}/members", + get(handlers_cohorts::get_cohort_members), + ) .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 6dcedcb..9639c8a 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -451,6 +451,36 @@ pub struct SaveNotePayload { pub content: String, } +// Cohorts & Groups +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct Cohort { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct UserCohort { + pub id: Uuid, + pub cohort_id: Uuid, + pub user_id: Uuid, + pub assigned_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCohortPayload { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddMemberPayload { + pub user_id: Uuid, +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 41ae383..fa8bfa7 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -19,6 +19,7 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer"; import DocumentPlayer from "@/components/blocks/DocumentPlayer"; import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer"; import InteractiveTranscript from "@/components/InteractiveTranscript"; +import AITutor from "@/components/AITutor"; import LessonLockedView from "@/components/LessonLockedView"; import StudentNotes from "@/components/StudentNotes"; import { ListMusic, StickyNote } from "lucide-react"; diff --git a/web/studio/src/app/admin/cohorts/page.tsx b/web/studio/src/app/admin/cohorts/page.tsx new file mode 100644 index 0000000..8a7683f --- /dev/null +++ b/web/studio/src/app/admin/cohorts/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { lmsApi, cmsApi, Cohort, User } from "@/lib/api"; +import { Users, Plus, UserPlus, X, Search, Trash2, Loader2, CheckCircle2 } from "lucide-react"; + +export default function CohortsPage() { + const [cohorts, setCohorts] = useState([]); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedCohort, setSelectedCohort] = useState(null); + const [memberIds, setMemberIds] = useState([]); + const [newCohortName, setNewCohortName] = useState(""); + const [newCohortDesc, setNewCohortDesc] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [fetchedCohorts, fetchedUsers] = await Promise.all([ + lmsApi.getCohorts(), + cmsApi.getAllUsers(), + ]); + setCohorts(fetchedCohorts); + setUsers(fetchedUsers); + if (fetchedCohorts.length > 0 && !selectedCohort) { + setSelectedCohort(fetchedCohorts[0]); + } + } catch (error) { + setMessage({ text: "Error loading cohorts or users", type: "error" }); + } finally { + setIsLoading(false); + } + }, [selectedCohort]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const loadMembers = useCallback(async (cohortId: string) => { + try { + const ids = await lmsApi.getMembers(cohortId); + setMemberIds(ids); + } catch (error) { + console.error("Error loading members:", error); + } + }, []); + + useEffect(() => { + if (selectedCohort) { + loadMembers(selectedCohort.id); + } + }, [selectedCohort, loadMembers]); + + useEffect(() => { + if (message) { + const timer = setTimeout(() => setMessage(null), 3000); + return () => clearTimeout(timer); + } + }, [message]); + + const handleCreateCohort = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newCohortName.trim()) return; + + setIsCreating(true); + try { + const cohort = await lmsApi.createCohort({ + name: newCohortName, + description: newCohortDesc, + }); + setCohorts([cohort, ...cohorts]); + setSelectedCohort(cohort); + setNewCohortName(""); + setNewCohortDesc(""); + setMessage({ text: "Cohort created successfully", type: "success" }); + } catch (error) { + setMessage({ text: "Error creating cohort", type: "error" }); + } finally { + setIsCreating(false); + } + }; + + const handleAddMember = async (userId: string) => { + if (!selectedCohort) return; + + try { + await lmsApi.addMember(selectedCohort.id, userId); + setMemberIds([...memberIds, userId]); + setMessage({ text: "Student added to cohort", type: "success" }); + } catch (error) { + setMessage({ text: "Error adding student", type: "error" }); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!selectedCohort) return; + + try { + await lmsApi.removeMember(selectedCohort.id, userId); + setMemberIds(memberIds.filter(id => id !== userId)); + setMessage({ text: "Student removed from cohort", type: "success" }); + } catch (error) { + setMessage({ text: "Error removing student", type: "error" }); + } + }; + + const filteredUsers = users.filter(user => + user.full_name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {message && ( +
+ {message.type === "success" ? : } + {message.text} + +
+ )} + +
+
+

Cohorts & Groups

+

Manage student segments for your organization.

+
+ +
+ +
+ {/* Cohorts List */} +
+

+ Cohorts +

+
+ {cohorts.map((cohort) => ( + + ))} +
+
+ + {/* Cohort Details & User Management */} +
+ {selectedCohort ? ( +
+
+
+

{selectedCohort.name}

+

{selectedCohort.description || "No description provided."}

+
+
+ +
+
+

Students

+
+ + ) => setSearchTerm(e.target.value)} + /> +
+
+ +
+ {filteredUsers.length === 0 ? ( +
+ No students found. +
+ ) : ( + filteredUsers.map((user) => { + const isMember = memberIds.includes(user.id); + return ( +
+
+
+ {user.full_name.charAt(0)} +
+
+
{user.full_name}
+
{user.email}
+
+
+
+ {isMember ? ( + + ) : ( + + )} +
+
+ ); + }) + )} +
+
+
+ ) : ( +
+
+ +

Create New Cohort

+

Define a new student segment to target specific learning experiences.

+
+
+
+ + ) => setNewCohortName(e.target.value)} + required + /> +
+
+ +