diff --git a/README.md b/README.md index ed92027..17ef5ae 100644 --- a/README.md +++ b/README.md @@ -1,239 +1,154 @@ -# OpenCCB - Open Comprehensive Course Backbone +# OpenCCB: Open Comprehensive Course Backbone -OpenCCB is a high-performance, microservices-based Learning Management System (LMS) and Content Management System (CMS) built with Rust (Edition 2024) and Next.js. The name stands for **Open Comprehensive Course Backbone**, representing the solid foundation for modern educational platforms. +OpenCCB es una infraestructura de código abierto para plataformas de gestión de aprendizaje y contenido (LMS/CMS), construida con rendimiento, seguridad y escalabilidad en mente. -## Architecture +## 🚀 Estado del Proyecto -- **CMS Service (Port 3001)**: Course management, content creation, grading policies, and administrative configurations. -- **LMS Service (Port 3002)**: Student experience, course consumption, enrollment, and grade tracking. -- **Shared Library**: Core models, authentication logic, and cross-service data contracts. -- **Database**: PostgreSQL with separate databases for CMS and LMS. -- **Studio (Frontend)**: Next.js application for instructors with block-based Activity Builder. -- **Experience (Frontend)**: Next.js student portal with interactive lesson player and progress dashboard. -- **Asset Storage**: Persistent local storage for native video/audio uploads. +El sistema se encuentra en una fase madura (**Phase 5 completada**), con una API robusta para la gestión de cursos, autenticación segura y análisis de datos. -## Getting Started +Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades. -### Prerequisites -- Docker & Docker Compose -- Rust (Edition 2024) -- Node.js (v18+) +## 🛠 Stack Tecnológico -### Running with Docker +- **Core**: Rust (Edition 2024) +- **API Framework**: Axum +- **Base de Datos**: PostgreSQL (con `sqlx`) +- **Autenticación**: JWT + RBAC (Roles: Admin, Instructor, Student) +- **Infraestructura**: Docker & Docker Compose -```bash -docker compose up -d --build -``` +## 🔌 API Reference (CMS Service) -### Access Points -- **Studio (Instructors)**: http://localhost:3000 -- **Experience (Students)**: http://localhost:3003 -- **CMS API**: http://localhost:3001 -- **LMS API**: http://localhost:3002 +El servicio CMS expone una API RESTful en el puerto `3001`. A continuación se detallan los contratos de los endpoints principales. -## Core Features +### 🔐 Autenticación -### 🎨 Content Creation & Management -- **Block-Based Activity Builder**: Create rich lessons using text, media, and interactive assessment blocks -- **Advanced Assessment Types**: - - Multiple Choice & True/False - - Fill-in-the-Blanks - - Matching Pairs - - Ordering/Sequencing - - Short Answer (with configurable correct answers) -- **Native File Uploads**: Drag-and-drop video/audio uploads with persistent storage -- **Playback Constraints**: Limit media views per student -- **Dynamic Content Reordering**: Organize blocks with move up/down controls -- **Course Settings**: Configure passing percentages and grading criteria +#### Registrar Usuario +- **URL**: `POST /auth/register` +- **Descripción**: Crea una nueva cuenta de usuario. +- **Body (JSON)**: + ```json + { + "email": "string (email format)", + "password": "string (min 8 chars)", + "role": "string ('instructor' | 'student')", + "organization_name": "string (optional)" + } + ``` -### 📊 Advanced Grading System -- **Holistic Grading Policy**: - - Create weighted grading categories (e.g., Homework 30%, Exams 70%) - - Drop lowest N scores per category - - Automatic weighted grade calculation -- **Configurable Assessment Policies**: - - Set maximum attempts per lesson (1-10 or unlimited) - - Enable/disable instant corrections and retries - - Atomic attempt tracking with enforcement -- **Dynamic Passing Thresholds**: - - Instructors set custom passing percentages (0-100%) - - 5-tier performance visualization for students: - - **Reprobado (Red)**: 0% to P-1% - - **Rendimiento Bajo (Orange)**: P% to P+9% - - **Rendimiento Medio (Yellow)**: P+10% to P+15% - - **Buen Rendimiento (Green)**: P+16% to 90% - - **Buen Rendimiento (Green)**: P+16% to 90% - - **Excelente (Blue)**: 91%+ -- **Automated Certificate Generation**: - - HTML-based customizable certificate templates - - Automatic download button upon passing a course - - PDF generation for print/save +#### Iniciar Sesión +- **URL**: `POST /auth/login` +- **Descripción**: Autentica un usuario y devuelve un token JWT. +- **Body (JSON)**: + ```json + { + "email": "string", + "password": "string" + } + ``` -### 📈 Analytics & Insights -- **Instructor Analytics Dashboard**: - - Total enrollments per course - - Overall average score across all assessments - - Per-lesson performance breakdown - - Automatic detection of "struggling lessons" (avg score < 70%) - - Visual performance charts -- **Student Progress Dashboard**: - - Real-time weighted grade calculation - - Category-by-category breakdown - - Interactive performance bar with tier visualization - - Lesson completion tracking +### 📚 Gestión de Cursos -### 🔐 Authentication & Security -- **JWT-Based Authentication**: Secure token-based auth across all services -- **Role-Based Access Control (RBAC)**: - - **Administrators**: Full platform access, global analytics, all course management - - **Instructors**: Course creation, analytics for assigned courses only - - **Students**: Course enrollment, lesson consumption, progress tracking -- **Service-to-Service Authorization**: Secure internal API calls with token validation -- **Audit Logging**: All CMS mutations recorded for compliance and debugging -- **Admin Audit Dashboard**: - - Visual interface to view system logs - - Diff viewer for JSON changes - - Advanced filtering by user and action +#### Listar Cursos +- **URL**: `GET /courses` +- **Descripción**: Obtiene la lista de cursos visibles para el usuario. -### 🚀 Service Integration -- **Automatic Sync**: One-click publish from CMS to LMS -- **Cross-Service Data Flow**: Courses, modules, lessons, and grading policies synchronized -- **Real-Time Updates**: Student progress immediately reflected in analytics +#### Crear Curso +- **URL**: `POST /courses` +- **Descripción**: Inicializa un nuevo curso. +- **Body (JSON)**: + ```json + { + "title": "string", + "description": "string (optional)", + "passing_percentage": "integer (0-100, default: 70)" + } + ``` -## API Documentation +#### Actualizar Curso +- **URL**: `PUT /courses/{id}` +- **Descripción**: Modifica metadatos del curso. +- **Body (JSON)**: + ```json + { + "title": "string", + "description": "string", + "passing_percentage": "integer", + "certificate_template": "string (HTML content)" + } + ``` -### CMS Service (`:3001`) +#### Publicar Curso +- **URL**: `POST /courses/{id}/publish` +- **Descripción**: Sincroniza el curso y su contenido con el servicio LMS. +- **Body**: `{}` (Vacío) -#### Authentication -```bash -# Register (Instructor/Admin) -curl -X POST http://localhost:3001/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email": "instructor@example.com", "password": "secure123", "full_name": "John Doe", "role": "instructor"}' +### 📦 Contenido (Módulos y Lecciones) -# Login -curl -X POST http://localhost:3001/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email": "instructor@example.com", "password": "secure123"}' -``` +#### Crear Módulo +- **URL**: `POST /modules` +- **Body (JSON)**: + ```json + { + "course_id": "uuid", + "title": "string", + "order_index": "integer" + } + ``` -#### Course Management -```bash -# Create Course -curl -X POST http://localhost:3001/courses \ - -H "Content-Type: application/json" \ - -d '{"title": "Advanced Rust 2024"}' +#### Crear Lección +- **URL**: `POST /lessons` +- **Body (JSON)**: + ```json + { + "module_id": "uuid", + "title": "string", + "content_type": "string ('video' | 'article' | 'quiz')", + "max_attempts": "integer (optional, null = unlimited)", + "allow_retry": "boolean (default: true)" + } + ``` -# Update Course Settings -curl -X PUT http://localhost:3001/courses/{id} \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{"passing_percentage": 75}' +#### Actualizar Lección +- **URL**: `PUT /lessons/{id}` +- **Body (JSON)**: + ```json + { + "title": "string", + "content_blocks": "array (JSON objects)", + "max_attempts": "integer", + "allow_retry": "boolean" + } + ``` -# Publish Course to LMS -curl -X POST http://localhost:3001/courses/{id}/publish \ - -H "Authorization: Bearer YOUR_TOKEN" +#### Transcripción AI (Simulado) +- **URL**: `POST /lessons/{id}/transcribe` +- **Descripción**: Inicia el proceso de generación de subtítulos/resumen. +- **Body**: `{}` (Vacío) -# Get Course Analytics (RBAC enforced) -curl http://localhost:3001/courses/{id}/analytics \ - -H "Authorization: Bearer YOUR_TOKEN" -``` +### 📂 Sistema & Assets -### LMS Service (`:3002`) +#### Subir Archivo +- **URL**: `POST /assets/upload` +- **Tipo**: `multipart/form-data` +- **Campo**: `file` (Binary) -#### Student Operations -```bash -# Register Student -curl -X POST http://localhost:3002/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email": "student@example.com", "password": "secure123", "full_name": "Jane Smith"}' +#### Logs de Auditoría +- **URL**: `GET /audit-logs` +- **Query Params**: `?page=1&limit=50` -# Get Course Catalog -curl http://localhost:3002/catalog +## 📦 Configuración y Ejecución -# Enroll in Course -curl -X POST http://localhost:3002/enroll \ - -H "Content-Type: application/json" \ - -d '{"user_id": "USER_UUID", "course_id": "COURSE_UUID"}' +1. **Variables de Entorno**: + Asegúrate de tener configurado `DATABASE_URL` en tu archivo `.env`. -# Submit Lesson Score -curl -X POST http://localhost:3002/grades \ - -H "Content-Type: application/json" \ - -d '{"user_id": "USER_UUID", "lesson_id": "LESSON_UUID", "score": 0.85}' +2. **Base de Datos**: + El sistema utiliza migraciones automáticas de `sqlx` al iniciar. -# Get Student Grades -curl http://localhost:3002/users/{user_id}/courses/{course_id}/grades -``` - -## Technology Stack - -### Backend -- **Rust 2024**: High-performance, memory-safe backend services -- **Axum 0.8**: Modern async web framework -- **SQLx**: Compile-time verified SQL queries -- **PostgreSQL**: Robust relational database -- **JWT**: Secure authentication tokens - -### Frontend -- **Next.js 14**: React framework with App Router -- **TypeScript**: Type-safe frontend development -- **Tailwind CSS**: Utility-first styling -- **Lucide React**: Modern icon library - -### DevOps -- **Docker**: Containerized deployment -- **Docker Compose**: Multi-service orchestration - -## Project Structure - -``` -openccb/ -├── services/ -│ ├── cms-service/ # Course management backend -│ │ ├── src/ -│ │ │ ├── handlers.rs # API handlers -│ │ │ └── main.rs # Service entry point -│ │ └── migrations/ # Database migrations -│ └── lms-service/ # Learning management backend -│ ├── src/ -│ │ ├── handlers.rs # API handlers -│ │ └── main.rs # Service entry point -│ └── migrations/ # Database migrations -├── shared/ -│ └── common/ # Shared models and auth -│ └── src/ -│ ├── models.rs # Data models -│ └── auth.rs # JWT utilities -├── web/ -│ ├── studio/ # Instructor frontend -│ │ └── src/ -│ │ ├── app/ # Next.js pages -│ │ ├── components/ # React components -│ │ └── lib/ # API client -│ └── experience/ # Student frontend -│ └── src/ -│ ├── app/ # Next.js pages -│ ├── components/ # React components -│ └── lib/ # API client -└── docker-compose.yml # Service orchestration -``` - -## Recent Enhancements - -### December 2024 -- ✅ **Holistic Grading System**: Weighted categories, drop policies, and automatic calculation -- ✅ **Attempt Tracking**: Configurable max attempts and retry policies per lesson -- ✅ **Instructor Analytics**: Course-level insights with RBAC enforcement -- ✅ **Dynamic Passing Thresholds**: Customizable pass marks with 5-tier performance visualization -- ✅ **Role-Based Access Control**: Admin, Instructor, and Student roles with granular permissions -- ✅ **Enhanced Progress Dashboard**: Real-time weighted grades and visual performance bars -- ✅ **Certificate System**: Custom HTML templates and automated generation -- ✅ **Quality Assurance**: Automated End-to-End (E2E) testing pipeline with Playwright - -## Contributing - -Contributions are welcome! Please ensure all tests pass and follow the existing code style. - -## License - -MIT License - see LICENSE file for details. +3. **Ejecutar Servicio**: + ```bash + cargo run --bin cms-service + ``` + O mediante Docker: + ```bash + docker-compose up --build + ``` \ No newline at end of file diff --git a/roadmap.md b/roadmap.md index 236b97a..b06824f 100644 --- a/roadmap.md +++ b/roadmap.md @@ -69,14 +69,18 @@ - [x] Tier-based feedback visualization - [x] Real-time grade updates -## Phase 6: Advanced Features (Planned) -- [ ] **Multi-tenancy**: Support for multiple organizations +## Phase 6: Advanced Features (In Progress) +- [x] **Multi-tenancy**: Support for multiple organizations + - [x] Database schema migration (add `organization_id`) + - [x] Update Rust models & JWT Claims + - [x] Implement Axum middleware for organization context + - [x] Update Frontend registration to support organizations - [ ] **Advanced Analytics**: - [ ] Cohort analysis - [ ] Retention metrics - [ ] Engagement heatmaps - [ ] **AI Integration**: - - [ ] AI-driven lesson summaries + - [x] AI-driven lesson summaries (Endpoint scaffolded/Simulated) - [ ] Automated quiz generation - [ ] Personalized learning paths - [ ] **Gamification**: @@ -114,12 +118,13 @@ **Platform Maturity**: Production-ready for core LMS/CMS functionality -**Recent Milestones** (December 2024): +**Recent Milestones** (January 2025): - ✅ Holistic grading system with weighted categories - ✅ Configurable assessment policies (attempts, retries) - ✅ Instructor analytics with RBAC - ✅ Dynamic passing thresholds with 5-tier visualization - ✅ Enhanced student progress dashboard +- ✅ Full REST API implementation for CMS (Courses, Modules, Lessons, Assets) **Next Priorities**: 1. Multi-tenancy support diff --git a/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql b/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql new file mode 100644 index 0000000..1312c49 --- /dev/null +++ b/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql @@ -0,0 +1,40 @@ +-- Migration: Add Multi-Tenancy Support (CMS) +-- Based on existing schema: users, courses, assets, audit_logs + +-- 1. Create organizations table +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. Create a default organization for existing data +INSERT INTO organizations (id, name) VALUES ('00000000-0000-0000-0000-000000000001', 'Default Organization'); + +-- 3. Add organization_id to tables with default value for existing rows +ALTER TABLE users ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; +ALTER TABLE courses ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; +ALTER TABLE assets ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; +ALTER TABLE audit_logs ADD COLUMN organization_id UUID; -- Nullable for system logs or pre-migration logs + +-- 4. Add Foreign Keys +ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE assets ADD CONSTRAINT fk_asset_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL; + +-- 5. Remove default values for future inserts (enforce explicit organization) +ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT; +ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT; +ALTER TABLE assets ALTER COLUMN organization_id DROP DEFAULT; + +-- 6. Update Unique Constraints for Users +-- Drop the global unique email constraint (created implicitly by UNIQUE in 20231219000003_users_table.sql) +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; + +-- Add composite unique index scoped to organization +CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email)); + +-- 7. Update Audit Logs to backfill organization based on user (optional best effort) +UPDATE audit_logs SET organization_id = u.organization_id FROM users u WHERE audit_logs.user_id = u.id; \ No newline at end of file diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 2a575dd..0bf7f28 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -3,23 +3,24 @@ use axum::{ http::StatusCode, Json, }; -use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics}; -use common::auth::{create_jwt, Claims}; +use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics, Organization}; +use common::auth::create_jwt; +use common::middleware::Org; use sqlx::PgPool; use uuid::Uuid; use serde_json::json; use serde::{Deserialize, Serialize}; use bcrypt::{hash, verify, DEFAULT_COST}; -use axum::http::HeaderMap; -use jsonwebtoken::{decode, DecodingKey, Validation}; pub async fn publish_course( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result { // 1. Fetch Course - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -103,17 +104,20 @@ pub struct GradingPayload { } pub async fn create_course( + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?; - let instructor_id = Uuid::new_v4(); + let instructor_id = claims.sub; let course = sqlx::query_as::<_, Course>( - "INSERT INTO courses (title, instructor_id) VALUES ($1, $2) RETURNING *" + "INSERT INTO courses (title, instructor_id, organization_id) VALUES ($1, $2, $3) RETURNING *" ) .bind(title) .bind(instructor_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -123,9 +127,11 @@ pub async fn create_course( Ok(Json(course)) } pub async fn get_courses( + Org(org_ctx): Org, State(pool): State, ) -> Result>, StatusCode> { - let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses") + let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -134,27 +140,16 @@ pub async fn get_courses( } pub async fn update_course( + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, Path(id): Path, - headers: HeaderMap, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // 1. RBAC check (simplified: must be owner or admin) - let auth_header = headers.get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?; - - let token = &auth_header[7..]; - let token_data = decode::( - token, - &DecodingKey::from_secret("secret".as_ref()), - &Validation::default(), - ).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".into()))?; - - let claims = token_data.claims; - - let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + // 1. Fetch course and check ownership/role + let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; @@ -177,13 +172,14 @@ pub async fn update_course( .or(existing.certificate_template); let course = sqlx::query_as::<_, Course>( - "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, updated_at = NOW() WHERE id = $5 RETURNING *" + "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, updated_at = NOW() WHERE id = $5 AND organization_id = $6 RETURNING *" ) .bind(title) .bind(description) .bind(passing_percentage) .bind(certificate_template) .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update course: {}", e)))?; @@ -192,6 +188,7 @@ pub async fn update_course( } pub async fn create_module( + claims: common::auth::Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { @@ -210,12 +207,13 @@ pub async fn create_module( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action(&pool, Uuid::new_v4(), "CREATE", "Module", module.id, json!({ "title": title, "course_id": course_id })).await; + log_action(&pool, claims.sub, "CREATE", "Module", module.id, json!({ "title": title, "course_id": course_id })).await; Ok(Json(module)) } pub async fn create_lesson( + claims: common::auth::Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { @@ -252,12 +250,13 @@ pub async fn create_lesson( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action(&pool, Uuid::new_v4(), "CREATE", "Lesson", lesson.id, json!({ "title": title, "module_id": module_id })).await; + log_action(&pool, claims.sub, "CREATE", "Lesson", lesson.id, json!({ "title": title, "module_id": module_id })).await; Ok(Json(lesson)) } pub async fn process_transcription( + claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { @@ -288,17 +287,20 @@ pub async fn process_transcription( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action(&pool, Uuid::new_v4(), "TRANSCRIPTION_PROCESSED", "Lesson", id, json!({})).await; + log_action(&pool, claims.sub, "TRANSCRIPTION_PROCESSED", "Lesson", id, json!({})).await; Ok(Json(updated_lesson)) } pub async fn get_lesson( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") + // Join to ensure lesson belongs to the organization + let lesson = sqlx::query_as::<_, Lesson>("SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id JOIN courses c ON m.course_id = c.id WHERE l.id = $1 AND c.organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -307,6 +309,7 @@ pub async fn get_lesson( } pub async fn update_lesson( + claims: common::auth::Claims, State(pool): State, Path(id): Path, Json(payload): Json, @@ -360,20 +363,22 @@ pub async fn update_lesson( StatusCode::INTERNAL_SERVER_ERROR })?; - log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await; + log_action(&pool, claims.sub, "UPDATE", "Lesson", id, json!(payload)).await; Ok(Json(updated_lesson)) } // Grading Policies pub async fn get_grading_categories( + Org(org_ctx): Org, State(pool): State, Path(course_id): Path, ) -> Result>, (StatusCode, String)> { let categories = sqlx::query_as::<_, common::models::GradingCategory>( - "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at" + "SELECT * FROM grading_categories WHERE course_id = $1 AND course_id IN (SELECT id FROM courses WHERE organization_id = $2) ORDER BY created_at" ) .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -435,11 +440,13 @@ async fn log_action( } pub async fn get_course( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -448,18 +455,21 @@ pub async fn get_course( } pub async fn get_modules( + Org(org_ctx): Org, State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { let modules = match query.course_id { Some(course_id) => { - sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 AND course_id IN (SELECT id FROM courses WHERE organization_id = $2) ORDER BY position") .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await } None => { - sqlx::query_as::<_, Module>("SELECT * FROM modules ORDER BY position") + sqlx::query_as::<_, Module>("SELECT m.* FROM modules m JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $1 ORDER BY m.position") + .bind(org_ctx.id) .fetch_all(&pool) .await } @@ -470,18 +480,21 @@ pub async fn get_modules( } pub async fn get_lessons( + Org(org_ctx): Org, State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { let lessons = match query.module_id { Some(module_id) => { - sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position") + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 AND module_id IN (SELECT m.id FROM modules m JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $2) ORDER BY position") .bind(module_id) + .bind(org_ctx.id) .fetch_all(&pool) .await } None => { - sqlx::query_as::<_, Lesson>("SELECT * FROM lessons ORDER BY position") + sqlx::query_as::<_, Lesson>("SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $1 ORDER BY l.position") + .bind(org_ctx.id) .fetch_all(&pool) .await } @@ -499,6 +512,7 @@ pub struct UploadResponse { } pub async fn upload_asset( + Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { @@ -538,13 +552,14 @@ pub async fn upload_asset( let size_bytes = tokio::fs::metadata(&storage_path).await.map(|m| m.len() as i64).unwrap_or(0); sqlx::query( - "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(asset_id) .bind(&filename) .bind(storage_path) .bind(mimetype) .bind(size_bytes) + .bind(org_ctx.id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -564,6 +579,7 @@ pub struct AuthPayload { pub password: String, pub full_name: Option, pub role: Option, + pub organization_name: Option, } pub async fn register( @@ -576,18 +592,37 @@ pub async fn register( let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("User").to_string()); let role = payload.role.unwrap_or_else(|| "instructor".to_string()); + // Find or create organization based on email domain + let org_name = payload.organization_name.unwrap_or_else(|| { + let parts: Vec<&str> = payload.email.split('@').collect(); + parts.get(1).unwrap_or(&"default.com").to_string() + }); + + let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let organization = sqlx::query_as::<_, Organization>( + "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" + ) + .bind(&org_name) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?; + let user = sqlx::query_as::<_, User>( - "INSERT INTO users (email, password_hash, full_name, role) VALUES ($1, $2, $3, $4) RETURNING *" + "INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *" ) .bind(&payload.email) .bind(password_hash) .bind(full_name) .bind(&role) - .fetch_one(&pool) + .bind(organization.id) + .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?; - let token = create_jwt(user.id, &user.role) + tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let token = create_jwt(user.id, user.organization_id, &user.role) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; Ok(Json(AuthResponse { @@ -615,7 +650,7 @@ pub async fn login( return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into())); } - let token = create_jwt(user.id, &user.role) + let token = create_jwt(user.id, user.organization_id, &user.role) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; Ok(Json(AuthResponse { @@ -629,39 +664,20 @@ pub async fn login( })) } pub async fn get_course_analytics( + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, - headers: HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { - // 1. Extract and verify token - let auth_header = headers.get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?; - - if !auth_header.starts_with("Bearer ") { - return Err((StatusCode::UNAUTHORIZED, "Invalid Authorization header".into())); - } - - let token = &auth_header[7..]; - let token_data = decode::( - token, - &DecodingKey::from_secret("secret".as_ref()), - &Validation::default(), - ).map_err(|e| { - tracing::error!("JWT decode failed: {}", e); - (StatusCode::UNAUTHORIZED, "Invalid token".into()) - })?; - - let claims = token_data.claims; - - // 2. Fetch Course to check ownership - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + // 1. Fetch Course to check ownership + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; - // 3. Enforce RBAC + // 2. Enforce RBAC if claims.role != "admin" && course.instructor_id != claims.sub { return Err((StatusCode::FORBIDDEN, "You do not have permission to view stats for a course you don't own".into())); } @@ -690,34 +706,17 @@ pub struct AuditQuery { } pub async fn get_audit_logs( + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, - headers: HeaderMap, Query(query): Query, ) -> Result>, (StatusCode, String)> { - // 1. Auth check - let auth_header = headers.get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?; - - if !auth_header.starts_with("Bearer ") { - return Err((StatusCode::UNAUTHORIZED, "Invalid Authorization header".into())); - } - - let token = &auth_header[7..]; - let token_data = decode::( - token, - &DecodingKey::from_secret("secret".as_ref()), - &Validation::default(), - ).map_err(|e| { - tracing::error!("JWT decode failed: {}", e); - (StatusCode::UNAUTHORIZED, "Invalid token".into()) - })?; - - if token_data.claims.role != "admin" { + // 1. RBAC check + if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Only admins can view audit logs".into())); } - // 2. Query + // 2. Query (filtered by organization) let limit = query.limit.unwrap_or(50); let offset = (query.page.unwrap_or(1) - 1) * limit; @@ -726,12 +725,14 @@ pub async fn get_audit_logs( SELECT a.id, a.user_id, u.full_name as user_full_name, a.action, a.entity_type, a.entity_id, a.changes, a.created_at FROM audit_logs a LEFT JOIN users u ON a.user_id = u.id + WHERE a.organization_id = $3 ORDER BY a.created_at DESC LIMIT $1 OFFSET $2 "# ) .bind(limit) .bind(offset) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index a9fe78b..ed3db70 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -1,8 +1,9 @@ mod handlers; use axum::{ - routing::{get, post, delete}, + routing::{get, post, delete, put}, Router, + middleware, }; use tower_http::cors::{Any, CorsLayer}; use sqlx::postgres::PgPoolOptions; @@ -33,28 +34,34 @@ async fn main() { .allow_methods(Any) .allow_headers(Any); - let app = Router::new() + // Rutas protegidas que requieren autenticación y contexto de organización + let protected_routes = Router::new() .route("/courses", get(handlers::get_courses).post(handlers::create_course)) - .route("/courses/{id}", get(handlers::get_course).put(handlers::update_course)) + .route("/courses/:id", get(handlers::get_course).put(handlers::update_course)) .route("/courses/{id}/publish", post(handlers::publish_course)) .route("/courses/{id}/analytics", get(handlers::get_course_analytics)) .route("/modules", get(handlers::get_modules).post(handlers::create_module)) .route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson)) - .route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson)) + .route("/lessons/:id", get(handlers::get_lesson).put(handlers::update_lesson)) .route("/lessons/{id}/transcribe", post(handlers::process_transcription)) - .route("/auth/register", post(handlers::register)) - .route("/auth/login", post(handlers::login)) .route("/grading", post(handlers::create_grading_category)) - .route("/grading/{id}", delete(handlers::delete_grading_category)) + .route("/grading/:id", delete(handlers::delete_grading_category)) .route("/courses/{id}/grading", get(handlers::get_grading_categories)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/assets/upload", post(handlers::upload_asset)) + .route_layer(middleware::from_fn(common::middleware::org_extractor_middleware)); + + // Rutas públicas que no requieren autenticación + let public_routes = Router::new() + .route("/auth/register", post(handlers::register)) + .route("/auth/login", post(handlers::login)) .nest_service("/assets", tower_http::services::ServeDir::new("uploads")) + .merge(protected_routes) .layer(cors) .with_state(pool); let addr = SocketAddr::from(([0, 0, 0, 0], 3001)); tracing::info!("CMS Service listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, public_routes).await.unwrap(); } diff --git a/services/lms-service/migrations/20250116100000_add_multi_tenancy.sql b/services/lms-service/migrations/20250116100000_add_multi_tenancy.sql new file mode 100644 index 0000000..6097570 --- /dev/null +++ b/services/lms-service/migrations/20250116100000_add_multi_tenancy.sql @@ -0,0 +1,35 @@ +-- Migration: Add Multi-Tenancy Support (LMS) +-- Based on existing schema: users, courses, enrollments + +-- 1. Create organizations table (Mirrors CMS structure) +CREATE TABLE organizations ( + id UUID PRIMARY KEY, -- ID synced from CMS + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. Create default organization +INSERT INTO organizations (id, name) VALUES ('00000000-0000-0000-0000-000000000001', 'Default Organization'); + +-- 3. Add organization_id to tables +ALTER TABLE users ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; +ALTER TABLE courses ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; +ALTER TABLE enrollments ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; + +-- 4. Add Foreign Keys +ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE enrollments ADD CONSTRAINT fk_enrollment_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +-- 5. Remove default values +ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT; +ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT; +ALTER TABLE enrollments ALTER COLUMN organization_id DROP DEFAULT; + +-- 6. Update Unique Constraints for Users +-- Drop global unique email constraint +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; + +-- Add composite unique index +CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email)); \ No newline at end of file diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index c63ada2..ad4f0b8 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -3,8 +3,9 @@ use axum::{ http::StatusCode, Json, }; -use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics}; -use common::auth::create_jwt; +use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics, Organization}; +use common::auth::{create_jwt, Claims}; +use common::middleware::Org; use sqlx::{PgPool, Row}; use uuid::Uuid; use serde::Deserialize; @@ -12,18 +13,20 @@ use bcrypt::{hash, verify, DEFAULT_COST}; pub async fn enroll_user( State(pool): State, + Org(org_ctx): Org, + claims: Claims, Json(payload): Json, ) -> Result, StatusCode> { let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?; let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; - let user_id_str = payload.get("user_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?; - let user_id = Uuid::parse_str(user_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; + let user_id = claims.sub; let enrollment = sqlx::query_as::<_, Enrollment>( - "INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2) RETURNING *" + "INSERT INTO enrollments (user_id, course_id, organization_id) VALUES ($1, $2, $3) RETURNING *" ) .bind(user_id) .bind(course_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| { @@ -39,6 +42,7 @@ pub struct AuthPayload { pub email: String, pub password: String, pub full_name: Option, + pub organization_name: Option, } #[derive(Deserialize)] @@ -59,17 +63,36 @@ pub async fn register( let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("Student").to_string()); + // Find or create organization + let org_name = payload.organization_name.unwrap_or_else(|| { + let parts: Vec<&str> = payload.email.split('@').collect(); + parts.get(1).unwrap_or(&"default.com").to_string() + }); + + let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let organization = sqlx::query_as::<_, Organization>( + "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" + ) + .bind(&org_name) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?; + let user = sqlx::query_as::<_, User>( - "INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *" + "INSERT INTO users (email, password_hash, full_name, organization_id, role) VALUES ($1, $2, $3, $4, 'student') RETURNING *" ) .bind(&payload.email) .bind(password_hash) .bind(full_name) - .fetch_one(&pool) + .bind(organization.id) + .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?; - let token = create_jwt(user.id, "student") + tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let token = create_jwt(user.id, user.organization_id, "student") .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; Ok(Json(AuthResponse { @@ -97,7 +120,7 @@ pub async fn login( return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into())); } - let token = create_jwt(user.id, "student") + let token = create_jwt(user.id, user.organization_id, "student") .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; Ok(Json(AuthResponse { @@ -129,9 +152,10 @@ pub async fn ingest_course( let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 1. Upsert Course + let org_id = payload.course.organization_id; sqlx::query( - "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -140,7 +164,8 @@ pub async fn ingest_course( end_date = EXCLUDED.end_date, passing_percentage = EXCLUDED.passing_percentage, certificate_template = EXCLUDED.certificate_template, - updated_at = EXCLUDED.updated_at" + updated_at = EXCLUDED.updated_at, + organization_id = EXCLUDED.organization_id" ) .bind(payload.course.id) .bind(&payload.course.title) @@ -151,6 +176,7 @@ pub async fn ingest_course( .bind(payload.course.passing_percentage) .bind(&payload.course.certificate_template) .bind(payload.course.updated_at) + .bind(org_id) .execute(&mut *tx) .await .map_err(|e| { @@ -174,8 +200,8 @@ pub async fn ingest_course( // 3. Insert Grading Categories for cat in payload.grading_categories { sqlx::query( - "INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at) - VALUES ($1, $2, $3, $4, $5, $6)" + "INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at, organization_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)" ) .bind(cat.id) .bind(payload.course.id) @@ -183,6 +209,7 @@ pub async fn ingest_course( .bind(cat.weight) .bind(cat.drop_count) .bind(cat.created_at) + .bind(org_id) .execute(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -191,22 +218,23 @@ pub async fn ingest_course( // 4. Insert Modules and Lessons for pub_module in payload.modules { sqlx::query( - "INSERT INTO modules (id, course_id, title, position, created_at) - VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO modules (id, course_id, title, position, created_at, organization_id) + VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(pub_module.module.id) .bind(payload.course.id) .bind(&pub_module.module.title) .bind(pub_module.module.position) .bind(pub_module.module.created_at) + .bind(org_id) .execute(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; for lesson in pub_module.lessons { sqlx::query( - "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)" + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" ) .bind(lesson.id) .bind(pub_module.module.id) @@ -221,6 +249,7 @@ pub async fn ingest_course( .bind(lesson.grading_category_id) .bind(lesson.max_attempts) .bind(lesson.allow_retry) + .bind(org_id) .execute(&mut *tx) .await .map_err(|e| { @@ -236,19 +265,22 @@ pub async fn ingest_course( } pub async fn get_course_outline( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 1. Fetch Course - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Fetch Modules - let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position") .bind(id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -258,6 +290,7 @@ pub async fn get_course_outline( "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at" ) .bind(id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -265,8 +298,9 @@ pub async fn get_course_outline( // 4. Fetch Lessons let mut pub_modules = Vec::new(); for module in modules { - let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position") + let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position") .bind(module.id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -285,11 +319,13 @@ pub async fn get_course_outline( } pub async fn get_lesson_content( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -298,11 +334,13 @@ pub async fn get_lesson_content( } pub async fn get_user_enrollments( + Org(org_ctx): Org, State(pool): State, Path(user_id): Path, ) -> Result>, StatusCode> { - let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1") + let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2") .bind(user_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -311,12 +349,14 @@ pub async fn get_user_enrollments( } pub async fn submit_lesson_score( + Org(org_ctx): Org, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { // 1. Get lesson attempt rules - let max_attempts: Option> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1") + let max_attempts: Option> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2") .bind(payload.lesson_id) + .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -327,9 +367,10 @@ pub async fn submit_lesson_score( let max_attempts = max_attempts.flatten(); // 2. Check existing grade/attempts - let existing_attempts: Option = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2") + let existing_attempts: Option = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2 AND organization_id = $3") .bind(payload.user_id) .bind(payload.lesson_id) + .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -345,8 +386,8 @@ pub async fn submit_lesson_score( // 3. Upsert with increment let grade = sqlx::query_as::<_, common::models::UserGrade>( - "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count) - VALUES ($1, $2, $3, $4, $5, 1) + "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count, organization_id) + VALUES ($1, $2, $3, $4, $5, 1, $6) ON CONFLICT (user_id, lesson_id) DO UPDATE SET score = EXCLUDED.score, metadata = EXCLUDED.metadata, @@ -359,6 +400,7 @@ pub async fn submit_lesson_score( .bind(payload.lesson_id) .bind(payload.score) .bind(payload.metadata) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -367,14 +409,16 @@ pub async fn submit_lesson_score( } pub async fn get_user_course_grades( + Org(org_ctx): Org, State(pool): State, Path((user_id, course_id)): Path<(Uuid, Uuid)>, ) -> Result>, StatusCode> { let grades = sqlx::query_as::<_, common::models::UserGrade>( - "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2" + "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3" ) .bind(user_id) .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -382,19 +426,22 @@ pub async fn get_user_course_grades( Ok(Json(grades)) } pub async fn get_course_analytics( + Org(org_ctx): Org, State(pool): State, Path(course_id): Path, ) -> Result, (StatusCode, String)> { // 1. Total Enrollments - let total_enrollments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM enrollments WHERE course_id = $1") + let total_enrollments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2") .bind(course_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 2. Average Course Score (Overall) - let average_score: Option = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1") + let average_score: Option = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2") .bind(course_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -410,12 +457,13 @@ pub async fn get_course_analytics( COUNT(g.id) as submission_count FROM lessons l LEFT JOIN user_grades g ON l.id = g.lesson_id - WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = $1) + WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = $1) AND l.organization_id = $2 GROUP BY l.id, l.title, l.position ORDER BY l.position "# ) .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 43b5dc1..a680e7d 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,8 +1,9 @@ mod handlers; use axum::{ - routing::{get, post}, + routing::{get, post, put}, Router, + middleware, }; use tower_http::cors::{Any, CorsLayer}; use sqlx::postgres::PgPoolOptions; @@ -33,23 +34,27 @@ async fn main() { .allow_methods(Any) .allow_headers(Any); - let app = Router::new() - .route("/catalog", get(handlers::get_course_catalog)) + let protected_routes = Router::new() .route("/enroll", post(handlers::enroll_user)) - .route("/ingest", post(handlers::ingest_course)) - .route("/auth/register", post(handlers::register)) - .route("/auth/login", post(handlers::login)) - .route("/enrollments/{id}", get(handlers::get_user_enrollments)) + .route("/enrollments/:id", get(handlers::get_user_enrollments)) .route("/courses/{id}/outline", get(handlers::get_course_outline)) - .route("/lessons/{id}", get(handlers::get_lesson_content)) + .route("/lessons/:id", get(handlers::get_lesson_content)) .route("/grades", post(handlers::submit_lesson_score)) .route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades)) .route("/courses/{id}/analytics", get(handlers::get_course_analytics)) + .route_layer(middleware::from_fn(common::middleware::org_extractor_middleware)); + + let public_routes = Router::new() + .route("/catalog", get(handlers::get_course_catalog)) + .route("/ingest", post(handlers::ingest_course)) + .route("/auth/register", post(handlers::register)) + .route("/auth/login", post(handlers::login)) + .merge(protected_routes) .layer(cors) .with_state(pool); let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); tracing::info!("LMS Service listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, public_routes).await.unwrap(); } diff --git a/shared/common/Cargo.toml b/shared/common/Cargo.toml index 8d0e5fc..4c0bc3f 100644 --- a/shared/common/Cargo.toml +++ b/shared/common/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true authors.workspace = true [dependencies] +axum = { workspace = true, features = ["macros"] } serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/shared/common/src/auth.rs b/shared/common/src/auth.rs index ee421c4..5aa0d94 100644 --- a/shared/common/src/auth.rs +++ b/shared/common/src/auth.rs @@ -6,11 +6,12 @@ use chrono::{Utc, Duration}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: Uuid, + pub org: Uuid, pub exp: i64, pub role: String, } -pub fn create_jwt(user_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") @@ -18,6 +19,7 @@ pub fn create_jwt(user_id: Uuid, role: &str) -> Result( + mut req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get("authorization") + .and_then(|header| header.to_str().ok()); + + let token = if let Some(token_str) = auth_header.and_then(|s| s.strip_prefix("Bearer ")) { + token_str + } 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 claims = decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)? + .claims; + + // Insertamos el contexto en las extensiones de la petición. + req.extensions_mut().insert(OrgContext { id: claims.org }); + + Ok(next.run(req).await) +} + +/// Extractor de Axum para acceder fácilmente al `OrgContext` en los handlers. +#[derive(Debug, Clone)] +pub struct Org(pub OrgContext); + +#[async_trait] +impl FromRequestParts for Org +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let org_context = parts.extensions.get::().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Contexto de organización no encontrado. ¿El middleware está configurado?", + ))?; + + Ok(Org(org_context.clone())) + } +} diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 7e48285..f5f3c22 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Course { pub id: Uuid, + pub organization_id: Uuid, pub title: String, pub description: Option, pub instructor_id: Uuid, @@ -68,6 +69,7 @@ pub struct UserGrade { pub struct AuditLog { pub id: Uuid, pub user_id: Uuid, + pub organization_id: Option, pub action: String, pub entity_type: String, pub entity_id: Uuid, @@ -91,6 +93,7 @@ pub struct AuditLogResponse { pub struct Enrollment { pub id: Uuid, pub user_id: Uuid, + pub organization_id: Uuid, pub course_id: Uuid, pub enroled_at: DateTime, } @@ -98,6 +101,7 @@ pub struct Enrollment { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Asset { pub id: Uuid, + pub organization_id: Uuid, pub filename: String, pub storage_path: String, pub mimetype: String, @@ -108,6 +112,7 @@ pub struct Asset { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct User { pub id: Uuid, + pub organization_id: Uuid, pub email: String, pub password_hash: String, pub full_name: String, diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index 96108aa..9df006f 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -3,13 +3,14 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { lmsApi } from "@/lib/api"; -import { GraduationCap, Lock, Mail, User } from "lucide-react"; +import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react"; export default function ExperienceLoginPage() { const router = useRouter(); const [isLogin, setIsLogin] = useState(true); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [organizationName, setOrganizationName] = useState(""); const [fullName, setFullName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -37,7 +38,8 @@ export default function ExperienceLoginPage() { const response = await lmsApi.register({ email, password, - full_name: fullName + full_name: fullName, + organization_name: organizationName, }); localStorage.setItem("experience_token", response.token); @@ -101,6 +103,24 @@ export default function ExperienceLoginPage() { )} + {!isLogin && ( +
+ +
+ + 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="Your School or Company" + /> +
+

If left blank, an organization will be created based on your email domain.

+
+ )}
+
+ +
+ + setOrganizationName(e.target.value)} + placeholder="Your School or Company" + 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" + /> +
+

If blank, we'll use your email domain.

+
+ + + )} + + ); +} + export default function RootLayout({ children, }: Readonly<{ @@ -17,26 +43,20 @@ export default function RootLayout({ }>) { return ( - + -
- -
- {children} -
+ STUDIO + + + +
{children}
); -} +} \ No newline at end of file diff --git a/web/studio/src/app/page.tsx b/web/studio/src/app/page.tsx index 897c406..c2d23e3 100644 --- a/web/studio/src/app/page.tsx +++ b/web/studio/src/app/page.tsx @@ -1,137 +1,87 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; import { cmsApi, Course } from "@/lib/api"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; +import { Plus, BookOpen } from "lucide-react"; -export default function Home() { - const router = useRouter(); - const { user } = useAuth(); +export default function StudioDashboard() { const [courses, setCourses] = useState([]); - const [mounted, setMounted] = useState(false); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { user } = useAuth(); useEffect(() => { - setMounted(true); - - // Authentication handled by AuthContext or manual check - // We keep this manual check as a legacy safe-guard or rely on AuthContext if we trust it fully. - // Given previous edits, we know AuthContext works. - if (typeof window !== 'undefined' && !localStorage.getItem("studio_user")) { - router.push("/auth/login"); - return; - } - - // Fetch courses const loadCourses = async () => { + if (!user) { + setLoading(false); + return; + }; try { - setLoading(true); const data = await cmsApi.getCourses(); setCourses(data); - setError(null); } catch (err) { - console.error("Failed to fetch courses:", err); - setError("Could not connect to CMS service. showing offline mode."); + console.error("Failed to load courses", err); } finally { setLoading(false); } }; - loadCourses(); - }, [router]); - + }, [user]); const handleCreateCourse = async () => { - const title = prompt("Enter course title:"); - if (!title) return; - - try { - const newCourse = await cmsApi.createCourse(title); - setCourses([...courses, newCourse]); - } catch { - alert("Failed to create course. Is the backend running?"); + const title = prompt("Enter new course title:"); + if (title) { + try { + const newCourse = await cmsApi.createCourse(title); + setCourses(prev => [...prev, newCourse]); + } catch (err) { + alert("Failed to create course. Please ensure the backend is running."); + } } }; - const placeholderCourses: Course[] = [ - { - id: "p1", - title: "Introduction to Rust (Demo)", - description: "A demo course to get started", - instructor_id: "demo", - passing_percentage: 70, - certificate_template: undefined, - created_at: new Date().toISOString() - }, - ]; - - const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses); - return ( -
-
-
-

My Courses

-

Manage and create your learning content

-
-
- {user?.role === 'admin' && ( - - - - )} - -
+
+
+

My Courses

+
- {error && ( -
- {error} + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : courses.length === 0 ? ( +
+

You haven't created any courses yet.

+
+ ) : ( +
+ {courses.map(course => ( + +
+
+
+ +
+

{course.title}

+

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

+
+
+ Last updated: {new Date(course.updated_at).toLocaleDateString()} + ID: {course.id.slice(0, 4)}... +
+
+ + ))}
)} - -
- {loading ? ( -
-
-

Loading your courses...

-
- ) : ( - <> - {displayCourses.map((course) => ( - -
-
- 📚 -
-

{course.title}

-
- - Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"} - - View Details → -
-
- - ))} - -
- - Add New Course -
- - )} -
); -} +} \ No newline at end of file diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 2fbc22e..d73e3ea 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -1,27 +1,15 @@ -export const API_BASE_URL = "http://localhost:3001"; +export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; export interface Course { id: string; title: string; - description: string; + description?: string; instructor_id: string; passing_percentage: number; certificate_template?: string; created_at: string; -} - -export interface CourseAnalytics { - course_id: string; - total_enrollments: number; - average_score: number; - lessons: LessonAnalytics[]; -} - -export interface LessonAnalytics { - lesson_id: string; - lesson_title: string; - average_score: number; - submission_count: number; + updated_at: string; + modules?: Module[]; } export interface Module { @@ -29,7 +17,6 @@ export interface Module { course_id: string; title: string; position: number; - created_at: string; lessons: Lesson[]; } @@ -40,19 +27,9 @@ export interface Block { content?: string; url?: string; media_type?: 'video' | 'audio'; - config?: { - maxPlays?: number; - currentPlays?: number; - allowDownload?: boolean; - }; + config?: any; quiz_data?: { - questions: { - id: string; - question: string; - options: string[]; - correct: number[]; - type?: 'multiple-choice' | 'true-false' | 'multiple-select'; - }[]; + questions: any[]; }; pairs?: { left: string; right: string }[]; items?: string[]; @@ -65,31 +42,16 @@ export interface Lesson { module_id: string; title: string; content_type: string; - content_url: string | null; - transcription?: { - en?: string; - es?: string; - cues?: { start: number; end: number; text: string }[]; - } | null; metadata?: { - blocks?: Block[]; - } | null; + blocks: Block[]; + }; is_graded: boolean; grading_category_id: string | null; max_attempts: number | null; allow_retry: boolean; - position: number; - created_at: string; + transcription?: any; } -export interface GradingCategory { - id: string; - course_id: string; - name: string; - weight: number; - drop_count: number; - created_at: string; -} export interface User { id: string; email: string; @@ -106,200 +68,114 @@ export interface AuthPayload { email: string; password?: string; full_name?: string; - role?: string; + role?: 'instructor' | 'admin'; + organization_name?: string; +} + +export interface UploadResponse { + id: string; + filename: string; + url: string; +} + +export interface GradingCategory { + id: string; + course_id: string; + name: string; + weight: number; + drop_count: number; } export interface AuditLog { id: string; user_id: string; - user_full_name?: string; + user_full_name: string | null; action: string; entity_type: string; entity_id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any changes: any; created_at: string; } +export interface CourseAnalytics { + course_id: string; + total_enrollments: number; + average_score: number; + lessons: { + lesson_id: string; + lesson_title: string; + average_score: number; + submission_count: number; + }[]; +} + +const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; + +const apiFetch = (url: string, options: RequestInit = {}) => { + const token = getToken(); + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }; + + return fetch(`${API_BASE_URL}${url}`, { ...options, headers }).then(res => { + if (!res.ok) { + return res.json().then(err => Promise.reject(err.message || 'An error occurred')); + } + // Handle no-content responses + if (res.status === 204) return; + return res.json(); + }); +}; + export const cmsApi = { - async getCourses(): Promise { - const response = await fetch(`${API_BASE_URL}/courses`); - if (!response.ok) throw new Error('Failed to fetch courses'); - return response.json(); - }, + // Auth + register: (payload: AuthPayload): Promise => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }), + login: (payload: AuthPayload): Promise => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(payload) }), - async createCourse(title: string): Promise { - const response = await fetch(`${API_BASE_URL}/courses`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), - }); - if (!response.ok) throw new Error('Failed to create course'); - return response.json(); - }, + // Courses + getCourses: (): Promise => apiFetch('/courses'), + createCourse: (title: string): Promise => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }), + getCourse: (id: string): Promise => apiFetch(`/courses/${id}`), + getCourseWithFullOutline: (id: string): Promise => apiFetch(`/courses/${id}/outline`), + updateCourse: (id: string, payload: Partial): Promise => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + publishCourse: (id: string): Promise => apiFetch(`/courses/${id}/publish`, { method: 'POST' }), - async getCourseWithFullOutline(courseId: string): Promise { - const course = await fetch(`${API_BASE_URL}/courses/${courseId}`).then(res => res.json()); - const modules = await fetch(`${API_BASE_URL}/modules?course_id=${courseId}`).then(res => res.json()); + // Modules & Lessons + createModule: (course_id: string, title: string, position: number): Promise => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }), + createLesson: (module_id: string, title: string, content_type: string, position: number): Promise => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }), + getLesson: (id: string): Promise => apiFetch(`/lessons/${id}`), + updateLesson: (id: string, payload: Partial): Promise => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), - const modulesWithLessons = await Promise.all(modules.map(async (m: Module) => { - const lessons = await fetch(`${API_BASE_URL}/lessons?module_id=${m.id}`).then(res => res.json()); - return { ...m, lessons }; - })); + // Grading + getGradingCategories: (courseId: string): Promise => apiFetch(`/courses/${courseId}/grading`), + createGradingCategory: (course_id: string, name: string, weight: number): Promise => apiFetch('/grading', { method: 'POST', body: JSON.stringify({ course_id, name, weight, drop_count: 0 }) }), + deleteGradingCategory: (id: string): Promise => apiFetch(`/grading/${id}`, { method: 'DELETE' }), - return { ...course, modules: modulesWithLessons }; - }, + // Admin & Analytics + getAuditLogs: (): Promise => apiFetch('/audit-logs'), + getCourseAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics`), - async createModule(courseId: string, title: string, position: number): Promise { - const response = await fetch(`${API_BASE_URL}/modules`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ course_id: courseId, title, position }), - }); - if (!response.ok) throw new Error('Failed to create module'); - return response.json(); - }, - - async createLesson(moduleId: string, title: string, contentType: string, position: number): Promise { - const response = await fetch(`${API_BASE_URL}/lessons`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ module_id: moduleId, title, content_type: contentType, position }), - }); - if (!response.ok) throw new Error('Failed to create lesson'); - return response.json(); - }, - - async transcribeLesson(lessonId: string): Promise { - const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}/transcribe`, { - method: 'POST' - }); - if (!response.ok) throw new Error('Failed to transcribe lesson'); - return response.json(); - }, - - async updateLesson(lessonId: string, updates: Partial): Promise { - const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); - if (!response.ok) throw new Error('Failed to update lesson'); - return response.json(); - }, - - async getLesson(lessonId: string): Promise { - const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`); - if (!response.ok) throw new Error('Failed to fetch lesson'); - return response.json(); - }, - - async getGradingCategories(courseId: string): Promise { - const response = await fetch(`${API_BASE_URL}/courses/${courseId}/grading`); - if (!response.ok) throw new Error('Failed to fetch grading categories'); - return response.json(); - }, - - async createGradingCategory(courseId: string, name: string, weight: number): Promise { - const response = await fetch(`${API_BASE_URL}/grading`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ course_id: courseId, name, weight, drop_count: 0 }), - }); - if (!response.ok) throw new Error('Failed to create grading category'); - return response.json(); - }, - - async deleteGradingCategory(id: string): Promise { - const response = await fetch(`${API_BASE_URL}/grading/${id}`, { - method: 'DELETE' - }); - if (!response.ok) throw new Error('Failed to delete grading category'); - }, - - async uploadAsset(file: File): Promise<{ id: string; filename: string; url: string }> { + // Assets + uploadAsset: (file: File): Promise => { const formData = new FormData(); formData.append('file', file); - const response = await fetch(`${API_BASE_URL}/assets/upload`, { + const token = getToken(); + const headers: Record = { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }; + + // Note: We don't set 'Content-Type' for multipart/form-data. + // The browser will set it automatically with the correct boundary. + return fetch(`${API_BASE_URL}/assets/upload`, { method: 'POST', + headers, body: formData, + }).then(res => { + if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Upload failed'))); + return res.json(); }); - - if (!response.ok) throw new Error('Upload failed'); - return response.json(); }, - - async publishCourse(courseId: string): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; - const response = await fetch(`${API_BASE_URL}/courses/${courseId}/publish`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - if (!response.ok) throw new Error('Failed to publish course'); - }, - - async register(payload: AuthPayload): Promise { - const response = await fetch(`${API_BASE_URL}/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (!response.ok) throw await response.json(); - return response.json(); - }, - - async login(payload: AuthPayload): Promise { - const response = await fetch(`${API_BASE_URL}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (!response.ok) throw await response.json(); - return response.json(); - }, - - async getCourseAnalytics(courseId: string): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; - const response = await fetch(`${API_BASE_URL}/courses/${courseId}/analytics`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - if (!response.ok) throw new Error('Failed to fetch course analytics'); - return response.json(); - }, - - async getCourse(id: string): Promise { - const response = await fetch(`${API_BASE_URL}/courses/${id}`); - if (!response.ok) throw new Error('Failed to fetch course'); - return response.json(); - }, - - async updateCourse(id: string, data: Partial): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; - const response = await fetch(`${API_BASE_URL}/courses/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(data) - }); - if (!response.ok) throw new Error('Failed to update course'); - return response.json(); - }, - - async getAuditLogs(page: number = 1, limit: number = 50): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; - const response = await fetch(`${API_BASE_URL}/audit-logs?page=${page}&limit=${limit}`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - if (!response.ok) throw new Error('Failed to fetch audit logs'); - return response.json(); - } -}; +}; \ No newline at end of file