feat: Implement multi-tenancy with new database migrations, API updates across services, and refactor frontend API calls.

This commit is contained in:
2025-12-26 10:59:07 -03:00
parent 8c440def23
commit e772d430b1
22 changed files with 819 additions and 745 deletions
+126 -211
View File
@@ -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
```
+9 -4
View File
@@ -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
@@ -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;
+89 -88
View File
@@ -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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, 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)?;
@@ -103,17 +104,20 @@ pub struct GradingPayload {
}
pub async fn create_course(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, 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<PgPool>,
) -> Result<Json<Vec<Course>>, 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<PgPool>,
Path(id): Path<Uuid>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, (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::<Claims>(
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<PgPool>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Module>, 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<PgPool>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Lesson>, 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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<serde_json::Value>,
@@ -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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<common::models::GradingCategory>>, (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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Course>, 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<PgPool>,
Query(query): Query<ModuleQuery>,
) -> Result<Json<Vec<Module>>, 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<PgPool>,
Query(query): Query<LessonQuery>,
) -> Result<Json<Vec<Lesson>>, 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<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<UploadResponse>, (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<String>,
pub role: Option<String>,
pub organization_name: Option<String>,
}
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<PgPool>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<CourseAnalytics>, (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::<Claims>(
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<PgPool>,
headers: HeaderMap,
Query(query): Query<AuditQuery>,
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (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::<Claims>(
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()))?;
+15 -8
View File
@@ -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();
}
@@ -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));
+79 -31
View File
@@ -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<PgPool>,
Org(org_ctx): Org,
claims: Claims,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Enrollment>, 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<String>,
pub organization_name: Option<String>,
}
#[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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, 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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, 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<PgPool>,
Json(payload): Json<GradeSubmissionPayload>,
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
// 1. Get lesson attempt rules
let max_attempts: Option<Option<i32>> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1")
let max_attempts: Option<Option<i32>> = 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<i32> = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2")
let existing_attempts: Option<i32> = 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<PgPool>,
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<common::models::UserGrade>>, 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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseAnalytics>, (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<f32> = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1")
let average_score: Option<f32> = 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()))?;
+14 -9
View File
@@ -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();
}
+1
View File
@@ -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
+3 -1
View File
@@ -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<String, jsonwebtoken::errors::Error> {
pub fn create_jwt(user_id: Uuid, organization_id: Uuid, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
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<String, jsonwebtoken::err
let claims = Claims {
sub: user_id,
org: organization_id,
exp: expiration,
role: role.to_string(),
};
+1
View File
@@ -1,3 +1,4 @@
pub mod models;
pub mod auth;
pub mod utils;
pub mod middleware;
+71
View File
@@ -0,0 +1,71 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, Request, StatusCode},
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use uuid::Uuid;
use crate::auth::Claims;
/// Contexto de la organización extraído del JWT.
#[derive(Debug, Clone)]
pub struct OrgContext {
pub id: Uuid,
}
/// Middleware que valida el token JWT y extrae el `organization_id`.
pub async fn org_extractor_middleware<B>(
mut req: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
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::<Claims>(
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<S> FromRequestParts<S> for Org
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let org_context = parts.extensions.get::<OrgContext>().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Contexto de organización no encontrado. ¿El middleware está configurado?",
))?;
Ok(Org(org_context.clone()))
}
}
+5
View File
@@ -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<String>,
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<Uuid>,
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<Utc>,
}
@@ -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,
+22 -2
View File
@@ -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() {
</div>
</div>
)}
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Organization Name (Optional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">If left blank, an organization will be created based on your email domain.</p>
</div>
)}
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
+18 -2
View File
@@ -5,12 +5,13 @@ import { lmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User } from "lucide-react";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -22,7 +23,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await lmsApi.register({ email, password, full_name: fullName });
const res = await lmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -97,6 +98,21 @@ export default function RegisterPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Organization Name (Optional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => 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"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">If blank, we'll use your email domain.</p>
</div>
<button
disabled={loading}
type="submit"
+1
View File
@@ -86,6 +86,7 @@ export interface AuthPayload {
email: string;
password?: string;
full_name?: string;
organization_name?: string;
}
export interface Enrollment {
+43 -21
View File
@@ -3,13 +3,16 @@
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi } from "@/lib/api";
import { BookOpen, Lock, Mail, User } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import { BookOpen, Lock, Mail, User, Building2 } from "lucide-react";
export default function StudioLoginPage() {
const router = useRouter();
const { login } = useAuth();
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("");
@@ -30,19 +33,18 @@ export default function StudioLoginPage() {
return;
}
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
} else {
const response = await cmsApi.register({
email,
password,
full_name: fullName,
role: "instructor"
role: "instructor",
organization_name: organizationName,
});
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
}
} catch (err) {
@@ -85,22 +87,42 @@ export default function StudioLoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
<>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Organization Name (Optional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => 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-blue-500"
placeholder="Your School or Company"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">
If left blank, an organization will be created based on your email domain.
</p>
</div>
</>
)}
<div>
+18 -2
View File
@@ -5,12 +5,13 @@ import { cmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User } from "lucide-react";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -22,7 +23,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await cmsApi.register({ email, password, full_name: fullName });
const res = await cmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -97,6 +98,21 @@ export default function RegisterPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Organization Name (Optional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => 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 placeholder:text-gray-700"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">If blank, we'll use your email domain.</p>
</div>
<button
disabled={loading}
type="submit"
+40 -23
View File
@@ -3,48 +3,65 @@
@tailwind utilities;
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 10, 10, 20;
--foreground-rgb: 229, 231, 235;
/* text-gray-200 */
--background-start-rgb: 15, 17, 21;
/* #0f1115 */
--background-end-rgb: 0, 0, 0;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
/* blue-500 */
--accent-secondary: #6366f1;
/* indigo-500 */
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(16px);
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
min-height: 100vh;
background: var(--background-start-rgb);
}
.glass {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.gradient-text {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.glass-card {
@apply glass rounded-2xl p-6;
}
.btn-premium {
@apply relative px-6 py-2 rounded-full font-medium transition-all duration-300;
@apply relative px-6 py-2.5 rounded-xl font-bold transition-all duration-300;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
}
.btn-premium:hover {
@apply scale-105;
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
@apply scale-[1.02] -translate-y-0.5 shadow-blue-500/40;
}
.btn-premium:active {
@apply scale-95;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+40 -20
View File
@@ -1,15 +1,41 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import Link from "next/link";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { BookOpen, LogOut, ShieldAlert } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "OpenCCB Studio | modern Course Management",
description: "Advanced LMS Content Management System inspired by Open edX",
title: "OpenCCB | Studio",
description: "Create and manage high-fidelity educational content.",
};
function AuthHeader() {
"use client";
const { user, logout } = useAuth();
return (
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<ShieldAlert size={16} /> Audit
</Link>
)}
{user && (
<>
<div className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs">
{user.full_name.charAt(0)}
</div>
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<LogOut size={16} />
</button>
</>
)}
</div>
);
}
export default function RootLayout({
children,
}: Readonly<{
@@ -17,26 +43,20 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen`}>
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
<AuthProvider>
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<h1 className="text-xl font-bold tracking-tight">
Open<span className="gradient-text">CCB</span> Studio
</h1>
<div className="flex gap-4">
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<BookOpen size={20} />
</div>
</div>
</nav>
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
{children}
</main>
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
</Link>
<AuthHeader />
</header>
<main className="flex-1">{children}</main>
</AuthProvider>
</body>
</html>
);
}
}
+54 -104
View File
@@ -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<Course[]>([]);
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">My Courses</h2>
<p className="text-gray-400">Manage and create your learning content</p>
</div>
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit">
<button className="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white border border-white/10 hover:border-white/30 rounded-lg transition-colors flex items-center gap-2">
🛡 Audit Logs
</button>
</Link>
)}
<button onClick={handleCreateCourse} className="btn-premium">
+ New Course
</button>
</div>
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Courses</h1>
<button onClick={handleCreateCourse} className="btn-premium flex items-center gap-2">
<Plus size={18} />
New Course
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
{error}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div>
))}
</div>
) : courses.length === 0 ? (
<div className="text-center py-20 glass-card border-dashed border-white/10">
<p className="text-gray-500">You haven't created any courses yet.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
<div className="flex-1">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<BookOpen className="text-blue-400" />
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500">
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span>
<span>ID: {course.id.slice(0, 4)}...</span>
</div>
</div>
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full py-20 text-center text-gray-500">
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
<p>Loading your courses...</p>
</div>
) : (
<>
{displayCourses.map((course) => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass p-6 hover:border-blue-500/50 transition-all group cursor-pointer h-full">
<div className="h-32 bg-gradient-to-br from-blue-900/50 to-purple-900/50 rounded-lg mb-4 flex items-center justify-center border border-white/5">
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
<div className="flex justify-between items-center pt-4 border-t border-white/5">
<span suppressHydrationWarning className="text-xs text-gray-500">
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
</span>
<span className="text-xs font-medium text-blue-400">View Details </span>
</div>
</div>
</Link>
))}
<div
onClick={handleCreateCourse}
className="glass p-6 border-dashed border-2 border-white/10 flex flex-col items-center justify-center text-gray-500 hover:border-white/20 transition-all cursor-pointer min-h-[300px]"
>
<span className="text-3xl mb-2"></span>
<span className="text-sm">Add New Course</span>
</div>
</>
)}
</div>
</div>
);
}
}
+95 -219
View File
@@ -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<Course[]> {
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<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
login: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(payload) }),
async createCourse(title: string): Promise<Course> {
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<Course[]> => apiFetch('/courses'),
createCourse: (title: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }),
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
getCourseWithFullOutline: (id: string): Promise<Course> => apiFetch(`/courses/${id}/outline`),
updateCourse: (id: string, payload: Partial<Course>): Promise<Course> => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
publishCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST' }),
async getCourseWithFullOutline(courseId: string): Promise<Course & { modules: Module[] }> {
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<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise<Lesson> => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => 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<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
createGradingCategory: (course_id: string, name: string, weight: number): Promise<GradingCategory> => apiFetch('/grading', { method: 'POST', body: JSON.stringify({ course_id, name, weight, drop_count: 0 }) }),
deleteGradingCategory: (id: string): Promise<void> => apiFetch(`/grading/${id}`, { method: 'DELETE' }),
return { ...course, modules: modulesWithLessons };
},
// Admin & Analytics
getAuditLogs: (): Promise<AuditLog[]> => apiFetch('/audit-logs'),
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
async createModule(courseId: string, title: string, position: number): Promise<Module> {
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<Lesson> {
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<Lesson> {
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<Lesson>): Promise<Lesson> {
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<Lesson> {
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<GradingCategory[]> {
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<GradingCategory> {
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<void> {
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<UploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/assets/upload`, {
const token = getToken();
const headers: Record<string, string> = {
...(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<void> {
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<AuthResponse> {
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<AuthResponse> {
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<CourseAnalytics> {
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<Course> {
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<Course>): Promise<Course> {
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<AuditLog[]> {
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();
}
};
};