diff --git a/README.md b/README.md index 89bdc26..32ecb32 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced). - **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. +- **Course Teams (Multi-instructor support)** - **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. diff --git a/roadmap.md b/roadmap.md index c9c287b..9cc7d77 100644 --- a/roadmap.md +++ b/roadmap.md @@ -104,6 +104,7 @@ - [x] **Mejoras en la Gestión de Cursos**: - [x] Nombrado manual de módulos, lecciones y actividades - [x] Pacing de cursos: Modo autodidacta (Evergreen) o Dirigido por instructor (Cohort) + - [x] Calendario de hitos y recordatorios automáticos de fechas límite ## Fase 8: Funcionalidades Enterprise (En Progreso) @@ -186,8 +187,8 @@ - [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. -- [x] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. ✅ -- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares. +- [x] **Bulk Operations**: Bulk enrollment, advanced grade export, and segmented announcements. +- [x] **Course Teams**: Support for multiple instructors per course with granular roles. - [ ] **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. diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 6f78916..d9c2ea6 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -8,7 +8,7 @@ use axum::{ }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; -use common::auth::{Claims, create_jwt}; +use common::auth::{Claims, create_jwt, create_preview_token}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, @@ -120,6 +120,7 @@ pub async fn publish_course( organization, grading_categories, modules: pub_modules, + dependencies: None, }; // 4. Send to LMS @@ -3288,6 +3289,164 @@ pub async fn import_course( Ok(Json(new_course)) } +pub async fn check_course_access( + pool: &PgPool, + course_id: Uuid, + user_id: Uuid, + role: &str, +) -> Result { + if role == "admin" { + return Ok(true); + } + + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2)" + ) + .bind(course_id) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(exists) +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct CourseInstructor { + pub id: Uuid, + pub course_id: Uuid, + pub user_id: Uuid, + pub role: String, + pub created_at: DateTime, + pub email: String, + pub full_name: String, +} + +pub async fn get_course_team( + Org(_org_ctx): Org, + claims: Claims, + State(pool): State, + Path(id): Path, +) -> Result>, (StatusCode, String)> { + if !check_course_access(&pool, id, claims.sub, &claims.role).await? { + return Err((StatusCode::FORBIDDEN, "No access to this course team".into())); + } + + let team = sqlx::query_as::<_, CourseInstructor>( + "SELECT ci.*, u.email, u.full_name FROM course_instructors ci + JOIN users u ON ci.user_id = u.id + WHERE ci.course_id = $1" + ) + .bind(id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(team)) +} + +#[derive(Deserialize)] +pub struct AddTeamMemberPayload { + pub email: String, + pub role: String, +} + +pub async fn add_team_member( + Org(_org_ctx): Org, + claims: Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Only primary instructors or admins can add members + let is_authorized = if claims.role == "admin" { + true + } else { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2 AND role = 'primary')" + ) + .bind(id) + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + if !is_authorized { + return Err((StatusCode::FORBIDDEN, "Only primary instructors can add team members".into())); + } + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(&payload.email) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "User not found".into()))?; + + let instructor = sqlx::query_as::<_, CourseInstructor>( + "INSERT INTO course_instructors (course_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING *, (SELECT email FROM users WHERE id = $2) as email, (SELECT full_name FROM users WHERE id = $2) as full_name" + ) + .bind(id) + .bind(user.id) + .bind(&payload.role) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok(Json(instructor)) +} + +pub async fn remove_team_member( + Org(_org_ctx): Org, + claims: Claims, + State(pool): State, + Path((course_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result { + let is_authorized = if claims.role == "admin" { + true + } else { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2 AND role = 'primary')" + ) + .bind(course_id) + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + if !is_authorized && claims.sub != user_id { + return Err((StatusCode::FORBIDDEN, "Unauthorized to remove this member".into())); + } + + sqlx::query("DELETE FROM course_instructors WHERE course_id = $1 AND user_id = $2") + .bind(course_id) + .bind(user_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn create_course_preview_token( + Org(_org_ctx): Org, + claims: Claims, + State(pool): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + // Verify user has access to this course (must be an instructor/admin) + if !check_course_access(&pool, id, claims.sub, &claims.role).await? { + return Err((StatusCode::FORBIDDEN, "No access to this course preview".into())); + } + + let token = create_preview_token(claims.sub, claims.org, id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(json!({ "token": token }))) +} + // --- AI Course Generation --- #[derive(Deserialize)] @@ -3540,7 +3699,7 @@ pub async fn delete_course( .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Additional permission check for instructors - if !is_super_admin && claims.role == "instructor" && course.instructor_id != claims.sub { + if !is_super_admin && !check_course_access(&pool, course.id, claims.sub, &claims.role).await? { return Err(StatusCode::FORBIDDEN); } diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index a75cd43..86ee9c1 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -104,6 +104,18 @@ async fn main() { "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), ) + .route( + "/courses/{id}/team", + get(handlers::get_course_team).post(handlers::add_team_member), + ) + .route( + "/courses/{id}/team/{user_id}", + delete(handlers::remove_team_member), + ) + .route( + "/courses/{id}/preview-token", + post(handlers::create_course_preview_token), + ) .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route( "/modules", diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 4abfad9..7a624c4 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -734,10 +734,32 @@ pub async fn ingest_course( pub async fn get_course_outline( Org(org_ctx): Org, + claims: Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - tracing::info!("get_course_outline: id={}, caller_org={}", id, org_ctx.id); + tracing::info!( + "get_course_outline: id={}, user={}, caller_org={}", + id, + claims.sub, + org_ctx.id + ); + + // If it's a preview token, ensure it's for the correct course + if claims.token_type.as_deref() == Some("preview") { + if claims.course_id != Some(id) { + tracing::warn!( + "get_course_outline: Preview token course_id mismatch. Token for {:?}, requested {}", + claims.course_id, + id + ); + return Err(StatusCode::FORBIDDEN); + } + tracing::info!( + "get_course_outline: Authorized via preview token for course {}", + id + ); + } // 1. Fetch Course let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) @@ -857,35 +879,59 @@ pub async fn get_lesson_content( claims.sub ); - // 1. Check if user is enrolled in the course this lesson belongs to - let lesson = sqlx::query_as::<_, Lesson>( - "SELECT l.* FROM lessons l - JOIN modules m ON l.module_id = m.id - JOIN enrollments e ON m.course_id = e.course_id - WHERE l.id = $1 AND e.user_id = $2", - ) - .bind(id) - .bind(claims.sub) - .fetch_optional(&pool) - .await - .map_err(|e| { - tracing::error!("get_lesson_content: DB error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + // 1. Check for preview token override + let is_preview = claims.token_type.as_deref() == Some("preview"); + + let lesson = if is_preview { + tracing::info!("get_lesson_content: Using preview token for lesson {}", id); + // Ensure the preview token is for the correct course (if we want to be strict) + // or just fetch the lesson and verify it belongs to the same org. + sqlx::query_as::<_, Lesson>( + "SELECT l.* FROM lessons l + JOIN modules m ON l.module_id = m.id + WHERE l.id = $1 AND l.organization_id = $2", + ) + .bind(id) + .bind(claims.org) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("get_lesson_content: DB error (preview): {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + } else { + sqlx::query_as::<_, Lesson>( + "SELECT l.* FROM lessons l + JOIN modules m ON l.module_id = m.id + JOIN enrollments e ON m.course_id = e.course_id + WHERE l.id = $1 AND e.user_id = $2", + ) + .bind(id) + .bind(claims.sub) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("get_lesson_content: DB error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + }; let lesson = match lesson { Some(l) => l, None => { tracing::warn!( - "get_lesson_content: User {} not enrolled or lesson {} not found", - claims.sub, - id + "get_lesson_content: Access denied or lesson {} not found (is_preview={})", + id, + is_preview ); return Err(StatusCode::FORBIDDEN); } }; - // 2. Enforce Prerequisites + // 2. Enforce Prerequisites (Skip for previews) + if is_preview { + return Ok(Json(lesson)); + } // We check if there are any prerequisites that the user hasn't completed yet. // A prerequisite is completed if: // a) It's graded and the user has a grade >= min_score_percentage (default 0) diff --git a/shared/common/src/auth.rs b/shared/common/src/auth.rs index 0dac7a8..e4e33a6 100644 --- a/shared/common/src/auth.rs +++ b/shared/common/src/auth.rs @@ -1,7 +1,7 @@ -use jsonwebtoken::{encode, Header, EncodingKey}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{EncodingKey, Header, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use chrono::{Utc, Duration}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { @@ -9,9 +9,15 @@ pub struct Claims { pub org: Uuid, pub exp: i64, pub role: String, + pub course_id: Option, + pub token_type: Option, // "access", "preview" } -pub fn create_jwt(user_id: Uuid, organization_id: Uuid, role: &str) -> Result { +pub fn create_jwt( + user_id: Uuid, + organization_id: Uuid, + role: &str, +) -> Result { let expiration = Utc::now() .checked_add_signed(Duration::hours(24)) .expect("valid timestamp") @@ -22,8 +28,41 @@ pub fn create_jwt(user_id: Uuid, organization_id: Uuid, role: &str) -> Result Result { + let expiration = Utc::now() + .checked_add_signed(Duration::hours(1)) + .expect("valid timestamp") + .timestamp(); + + let claims = Claims { + sub: user_id, + org: organization_id, + exp: expiration, + role: "instructor".to_string(), + course_id: Some(course_id), + token_type: Some("preview".to_string()), + }; + + let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) } diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index 5fa916f..b526baf 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -26,16 +26,27 @@ pub async fn org_extractor_middleware( .and_then(|header: &axum::http::HeaderValue| header.to_str().ok()); let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) { - token_str + token_str.to_string() } else { - return Err(StatusCode::UNAUTHORIZED); + // Check for preview_token in query string + let query = req.uri().query().unwrap_or_default(); + let preview_token = query + .split('&') + .find(|part| part.starts_with("preview_token=")) + .and_then(|part| part.split('=').nth(1)); + + if let Some(token) = preview_token { + token.to_string() + } else { + return Err(StatusCode::UNAUTHORIZED); + } }; // NOTA: El secreto debe venir de una variable de entorno en producción. let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); let mut claims = decode::( - token, + &token, &DecodingKey::from_secret(secret.as_ref()), &Validation::default(), ) diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 4fb547c..793a712 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -32,7 +32,15 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } setUserGrades(grades); const enrollmentData = await lmsApi.getEnrollments(user.id); - setIsEnrolled(enrollmentData.some(e => e.course_id === params.id)); + const enrolled = enrollmentData.some(e => e.course_id === params.id); + + // Allow preview token to override enrollment status + const isPreview = typeof window !== 'undefined' && !!sessionStorage.getItem('preview_token'); + setIsEnrolled(enrolled || isPreview); + } else { + // Even if not logged in, if there's a preview token, consider "enrolled" for UI + const isPreview = typeof window !== 'undefined' && !!sessionStorage.getItem('preview_token'); + if (isPreview) setIsEnrolled(true); } } catch (err) { console.error(err); diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 3ee32bd..5c61bee 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -346,7 +346,20 @@ export interface UpdateAnnouncementPayload { -const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null; +const getToken = () => { + if (typeof window === 'undefined') return null; + + // Check for preview token in URL + const urlParams = new URLSearchParams(window.location.search); + const previewToken = urlParams.get('preview_token'); + + if (previewToken) { + sessionStorage.setItem('preview_token', previewToken); + return previewToken; + } + + return sessionStorage.getItem('preview_token') || localStorage.getItem('experience_token'); +}; const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => { const token = getToken(); diff --git a/web/studio/src/app/courses/[id]/page.tsx b/web/studio/src/app/courses/[id]/page.tsx index 5616c5a..f203468 100644 --- a/web/studio/src/app/courses/[id]/page.tsx +++ b/web/studio/src/app/courses/[id]/page.tsx @@ -246,8 +246,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
- +
+ {error &&

{error}

} + + + {/* Member List */} +
+

Active Instructors

+
+ {team.map((member) => ( +
+
+
+ {member.full_name.charAt(0)} +
+
+
+ {member.full_name} + {member.role === 'primary' && ( + + Primary + + )} + {member.role === 'assistant' && ( + + Assistant + + )} +
+
{member.email}
+
+
+
+ {member.role !== 'primary' && ( + + )} +
+
+ ))} + {team.length === 0 && ( +
+ No instructors added yet. +
+ )} +
+
+ + ); +} diff --git a/web/studio/src/app/courses/[id]/settings/page.tsx b/web/studio/src/app/courses/[id]/settings/page.tsx index f153508..d4c5a35 100644 --- a/web/studio/src/app/courses/[id]/settings/page.tsx +++ b/web/studio/src/app/courses/[id]/settings/page.tsx @@ -21,6 +21,7 @@ const DEFAULT_CERTIFICATE_TEMPLATE = ` `; import CourseEditorLayout from "@/components/CourseEditorLayout"; +import TeamManagementSection from "./TeamManagementSection"; export default function CourseSettingsPage() { const { id } = useParams() as { id: string }; @@ -164,286 +165,289 @@ export default function CourseSettingsPage() { +
+ - {/* Passing Percentage Section */} -
-
-
- -
-

Grading Configuration

-
- -
-
- -
- setPassingPercentage(parseInt(e.target.value))} - className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- {passingPercentage}% -
+ {/* Passing Percentage Section */} +
+
+
+
-

- Students must achieve at least this percentage to pass the course. -

+

Grading Configuration

- {/* Performance Tiers Preview */} -
-

Performance Tiers Preview

-
-
-
- Reprobado: - 0% - {Math.max(0, passingPercentage - 1)}% -
-
-
- Rendimiento Bajo: - {passingPercentage}% - {passingPercentage + 9}% -
-
-
- Rendimiento Medio: - {passingPercentage + 10}% - {passingPercentage + 15}% -
-
-
- Buen Rendimiento: - {passingPercentage + 16}% - 90% -
-
-
- Excelente: - 91% - 100% -
-
-
-
-
- - {/* Course Pacing Section */} -
-
-
- -
-

Course Pacing & Schedule

-
- -
-
- -
- - -
-
- - {pacingMode === 'instructor_led' && ( -
- -
-
- -
- - setStartDate(e.target.value)} - className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500" - /> -
-
-
- -
- - setEndDate(e.target.value)} - className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500" - /> -
-
-
-
- )} -
-
- - {/* Course Pricing Section */} -
-
-
- $ -
-

Course Pricing

-
- -
-
- -
- setPrice(parseFloat(e.target.value))} - className="w-full bg-black/30 border border-white/10 rounded-xl py-3 px-4 text-white focus:outline-none focus:border-blue-500 transition-colors" - placeholder="0.00" - /> -
- {currency} -
-
-

Set to 0 for a free course.

-
- -
- - -
-
-
- - {/* Certificate Template Section */} -
-
-
- -
-

Certificate Template

-
- -
-

- Design the HTML certificate that students will receive upon passing the course. - Available variables: {"{{student_name}}"}, {"{{course_title}}"}, {"{{date}}"}, {"{{score}}"}. -

- -
-
- -