feat: Implement multi-tenancy with new database migrations, API updates across services, and refactor frontend API calls.
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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));
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod models;
|
||||
pub mod auth;
|
||||
pub mod utils;
|
||||
pub mod middleware;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface AuthPayload {
|
||||
email: string;
|
||||
password?: string;
|
||||
full_name?: string;
|
||||
organization_name?: string;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,24 +43,18 @@ 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>
|
||||
|
||||
+53
-103
@@ -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>
|
||||
);
|
||||
}
|
||||
+94
-218
@@ -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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user