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.
|
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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Getting Started
|
Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades.
|
||||||
|
|
||||||
### Prerequisites
|
## 🛠 Stack Tecnológico
|
||||||
- Docker & Docker Compose
|
|
||||||
- Rust (Edition 2024)
|
|
||||||
- Node.js (v18+)
|
|
||||||
|
|
||||||
### 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
|
## 🔌 API Reference (CMS Service)
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Access Points
|
El servicio CMS expone una API RESTful en el puerto `3001`. A continuación se detallan los contratos de los endpoints principales.
|
||||||
- **Studio (Instructors)**: http://localhost:3000
|
|
||||||
- **Experience (Students)**: http://localhost:3003
|
|
||||||
- **CMS API**: http://localhost:3001
|
|
||||||
- **LMS API**: http://localhost:3002
|
|
||||||
|
|
||||||
## Core Features
|
### 🔐 Autenticación
|
||||||
|
|
||||||
### 🎨 Content Creation & Management
|
#### Registrar Usuario
|
||||||
- **Block-Based Activity Builder**: Create rich lessons using text, media, and interactive assessment blocks
|
- **URL**: `POST /auth/register`
|
||||||
- **Advanced Assessment Types**:
|
- **Descripción**: Crea una nueva cuenta de usuario.
|
||||||
- Multiple Choice & True/False
|
- **Body (JSON)**:
|
||||||
- Fill-in-the-Blanks
|
```json
|
||||||
- Matching Pairs
|
{
|
||||||
- Ordering/Sequencing
|
"email": "string (email format)",
|
||||||
- Short Answer (with configurable correct answers)
|
"password": "string (min 8 chars)",
|
||||||
- **Native File Uploads**: Drag-and-drop video/audio uploads with persistent storage
|
"role": "string ('instructor' | 'student')",
|
||||||
- **Playback Constraints**: Limit media views per student
|
"organization_name": "string (optional)"
|
||||||
- **Dynamic Content Reordering**: Organize blocks with move up/down controls
|
}
|
||||||
- **Course Settings**: Configure passing percentages and grading criteria
|
```
|
||||||
|
|
||||||
### 📊 Advanced Grading System
|
#### Iniciar Sesión
|
||||||
- **Holistic Grading Policy**:
|
- **URL**: `POST /auth/login`
|
||||||
- Create weighted grading categories (e.g., Homework 30%, Exams 70%)
|
- **Descripción**: Autentica un usuario y devuelve un token JWT.
|
||||||
- Drop lowest N scores per category
|
- **Body (JSON)**:
|
||||||
- Automatic weighted grade calculation
|
```json
|
||||||
- **Configurable Assessment Policies**:
|
{
|
||||||
- Set maximum attempts per lesson (1-10 or unlimited)
|
"email": "string",
|
||||||
- Enable/disable instant corrections and retries
|
"password": "string"
|
||||||
- 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
|
|
||||||
|
|
||||||
### 📈 Analytics & Insights
|
### 📚 Gestión de Cursos
|
||||||
- **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
|
|
||||||
|
|
||||||
### 🔐 Authentication & Security
|
#### Listar Cursos
|
||||||
- **JWT-Based Authentication**: Secure token-based auth across all services
|
- **URL**: `GET /courses`
|
||||||
- **Role-Based Access Control (RBAC)**:
|
- **Descripción**: Obtiene la lista de cursos visibles para el usuario.
|
||||||
- **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
|
|
||||||
|
|
||||||
### 🚀 Service Integration
|
#### Crear Curso
|
||||||
- **Automatic Sync**: One-click publish from CMS to LMS
|
- **URL**: `POST /courses`
|
||||||
- **Cross-Service Data Flow**: Courses, modules, lessons, and grading policies synchronized
|
- **Descripción**: Inicializa un nuevo curso.
|
||||||
- **Real-Time Updates**: Student progress immediately reflected in analytics
|
- **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
|
### 📦 Contenido (Módulos y Lecciones)
|
||||||
```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"}'
|
|
||||||
|
|
||||||
# Login
|
#### Crear Módulo
|
||||||
curl -X POST http://localhost:3001/auth/login \
|
- **URL**: `POST /modules`
|
||||||
-H "Content-Type: application/json" \
|
- **Body (JSON)**:
|
||||||
-d '{"email": "instructor@example.com", "password": "secure123"}'
|
```json
|
||||||
```
|
{
|
||||||
|
"course_id": "uuid",
|
||||||
|
"title": "string",
|
||||||
|
"order_index": "integer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Course Management
|
#### Crear Lección
|
||||||
```bash
|
- **URL**: `POST /lessons`
|
||||||
# Create Course
|
- **Body (JSON)**:
|
||||||
curl -X POST http://localhost:3001/courses \
|
```json
|
||||||
-H "Content-Type: application/json" \
|
{
|
||||||
-d '{"title": "Advanced Rust 2024"}'
|
"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
|
#### Actualizar Lección
|
||||||
curl -X PUT http://localhost:3001/courses/{id} \
|
- **URL**: `PUT /lessons/{id}`
|
||||||
-H "Content-Type: application/json" \
|
- **Body (JSON)**:
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
```json
|
||||||
-d '{"passing_percentage": 75}'
|
{
|
||||||
|
"title": "string",
|
||||||
|
"content_blocks": "array (JSON objects)",
|
||||||
|
"max_attempts": "integer",
|
||||||
|
"allow_retry": "boolean"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
# Publish Course to LMS
|
#### Transcripción AI (Simulado)
|
||||||
curl -X POST http://localhost:3001/courses/{id}/publish \
|
- **URL**: `POST /lessons/{id}/transcribe`
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
- **Descripción**: Inicia el proceso de generación de subtítulos/resumen.
|
||||||
|
- **Body**: `{}` (Vacío)
|
||||||
|
|
||||||
# Get Course Analytics (RBAC enforced)
|
### 📂 Sistema & Assets
|
||||||
curl http://localhost:3001/courses/{id}/analytics \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### LMS Service (`:3002`)
|
#### Subir Archivo
|
||||||
|
- **URL**: `POST /assets/upload`
|
||||||
|
- **Tipo**: `multipart/form-data`
|
||||||
|
- **Campo**: `file` (Binary)
|
||||||
|
|
||||||
#### Student Operations
|
#### Logs de Auditoría
|
||||||
```bash
|
- **URL**: `GET /audit-logs`
|
||||||
# Register Student
|
- **Query Params**: `?page=1&limit=50`
|
||||||
curl -X POST http://localhost:3002/auth/register \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email": "student@example.com", "password": "secure123", "full_name": "Jane Smith"}'
|
|
||||||
|
|
||||||
# Get Course Catalog
|
## 📦 Configuración y Ejecución
|
||||||
curl http://localhost:3002/catalog
|
|
||||||
|
|
||||||
# Enroll in Course
|
1. **Variables de Entorno**:
|
||||||
curl -X POST http://localhost:3002/enroll \
|
Asegúrate de tener configurado `DATABASE_URL` en tu archivo `.env`.
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "USER_UUID", "course_id": "COURSE_UUID"}'
|
|
||||||
|
|
||||||
# Submit Lesson Score
|
2. **Base de Datos**:
|
||||||
curl -X POST http://localhost:3002/grades \
|
El sistema utiliza migraciones automáticas de `sqlx` al iniciar.
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "USER_UUID", "lesson_id": "LESSON_UUID", "score": 0.85}'
|
|
||||||
|
|
||||||
# Get Student Grades
|
3. **Ejecutar Servicio**:
|
||||||
curl http://localhost:3002/users/{user_id}/courses/{course_id}/grades
|
```bash
|
||||||
```
|
cargo run --bin cms-service
|
||||||
|
```
|
||||||
## Technology Stack
|
O mediante Docker:
|
||||||
|
```bash
|
||||||
### Backend
|
docker-compose up --build
|
||||||
- **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.
|
|
||||||
+9
-4
@@ -69,14 +69,18 @@
|
|||||||
- [x] Tier-based feedback visualization
|
- [x] Tier-based feedback visualization
|
||||||
- [x] Real-time grade updates
|
- [x] Real-time grade updates
|
||||||
|
|
||||||
## Phase 6: Advanced Features (Planned)
|
## Phase 6: Advanced Features (In Progress)
|
||||||
- [ ] **Multi-tenancy**: Support for multiple organizations
|
- [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**:
|
- [ ] **Advanced Analytics**:
|
||||||
- [ ] Cohort analysis
|
- [ ] Cohort analysis
|
||||||
- [ ] Retention metrics
|
- [ ] Retention metrics
|
||||||
- [ ] Engagement heatmaps
|
- [ ] Engagement heatmaps
|
||||||
- [ ] **AI Integration**:
|
- [ ] **AI Integration**:
|
||||||
- [ ] AI-driven lesson summaries
|
- [x] AI-driven lesson summaries (Endpoint scaffolded/Simulated)
|
||||||
- [ ] Automated quiz generation
|
- [ ] Automated quiz generation
|
||||||
- [ ] Personalized learning paths
|
- [ ] Personalized learning paths
|
||||||
- [ ] **Gamification**:
|
- [ ] **Gamification**:
|
||||||
@@ -114,12 +118,13 @@
|
|||||||
|
|
||||||
**Platform Maturity**: Production-ready for core LMS/CMS functionality
|
**Platform Maturity**: Production-ready for core LMS/CMS functionality
|
||||||
|
|
||||||
**Recent Milestones** (December 2024):
|
**Recent Milestones** (January 2025):
|
||||||
- ✅ Holistic grading system with weighted categories
|
- ✅ Holistic grading system with weighted categories
|
||||||
- ✅ Configurable assessment policies (attempts, retries)
|
- ✅ Configurable assessment policies (attempts, retries)
|
||||||
- ✅ Instructor analytics with RBAC
|
- ✅ Instructor analytics with RBAC
|
||||||
- ✅ Dynamic passing thresholds with 5-tier visualization
|
- ✅ Dynamic passing thresholds with 5-tier visualization
|
||||||
- ✅ Enhanced student progress dashboard
|
- ✅ Enhanced student progress dashboard
|
||||||
|
- ✅ Full REST API implementation for CMS (Courses, Modules, Lessons, Assets)
|
||||||
|
|
||||||
**Next Priorities**:
|
**Next Priorities**:
|
||||||
1. Multi-tenancy support
|
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,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics};
|
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics, Organization};
|
||||||
use common::auth::{create_jwt, Claims};
|
use common::auth::create_jwt;
|
||||||
|
use common::middleware::Org;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
use axum::http::HeaderMap;
|
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
// 1. Fetch Course
|
// 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -103,17 +104,20 @@ pub struct GradingPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_course(
|
pub async fn create_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
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>(
|
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(title)
|
||||||
.bind(instructor_id)
|
.bind(instructor_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -123,9 +127,11 @@ pub async fn create_course(
|
|||||||
Ok(Json(course))
|
Ok(Json(course))
|
||||||
}
|
}
|
||||||
pub async fn get_courses(
|
pub async fn get_courses(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> 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)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -134,27 +140,16 @@ pub async fn get_courses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_course(
|
pub async fn update_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
// 1. RBAC check (simplified: must be owner or admin)
|
// 1. Fetch course and check ownership/role
|
||||||
let auth_header = headers.get("Authorization")
|
let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.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")
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||||
@@ -177,13 +172,14 @@ pub async fn update_course(
|
|||||||
.or(existing.certificate_template);
|
.or(existing.certificate_template);
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
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(title)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(passing_percentage)
|
.bind(passing_percentage)
|
||||||
.bind(certificate_template)
|
.bind(certificate_template)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update course: {}", e)))?;
|
.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(
|
pub async fn create_module(
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Module>, StatusCode> {
|
) -> Result<Json<Module>, StatusCode> {
|
||||||
@@ -210,12 +207,13 @@ pub async fn create_module(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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))
|
Ok(Json(module))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_lesson(
|
pub async fn create_lesson(
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
@@ -252,12 +250,13 @@ pub async fn create_lesson(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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))
|
Ok(Json(lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_transcription(
|
pub async fn process_transcription(
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
@@ -288,17 +287,20 @@ pub async fn process_transcription(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_lesson(
|
pub async fn get_lesson(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -307,6 +309,7 @@ pub async fn get_lesson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_lesson(
|
pub async fn update_lesson(
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
@@ -360,20 +363,22 @@ pub async fn update_lesson(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
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))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grading Policies
|
// Grading Policies
|
||||||
pub async fn get_grading_categories(
|
pub async fn get_grading_categories(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(course_id): Path<Uuid>,
|
Path(course_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<common::models::GradingCategory>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<common::models::GradingCategory>>, (StatusCode, String)> {
|
||||||
let categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
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(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -435,11 +440,13 @@ async fn log_action(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_course(
|
pub async fn get_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
) -> 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -448,18 +455,21 @@ pub async fn get_course(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_modules(
|
pub async fn get_modules(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Query(query): Query<ModuleQuery>,
|
Query(query): Query<ModuleQuery>,
|
||||||
) -> Result<Json<Vec<Module>>, StatusCode> {
|
) -> Result<Json<Vec<Module>>, StatusCode> {
|
||||||
let modules = match query.course_id {
|
let modules = match query.course_id {
|
||||||
Some(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(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
None => {
|
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)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -470,18 +480,21 @@ pub async fn get_modules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_lessons(
|
pub async fn get_lessons(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Query(query): Query<LessonQuery>,
|
Query(query): Query<LessonQuery>,
|
||||||
) -> Result<Json<Vec<Lesson>>, StatusCode> {
|
) -> Result<Json<Vec<Lesson>>, StatusCode> {
|
||||||
let lessons = match query.module_id {
|
let lessons = match query.module_id {
|
||||||
Some(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(module_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
None => {
|
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)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -499,6 +512,7 @@ pub struct UploadResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_asset(
|
pub async fn upload_asset(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
mut multipart: axum::extract::Multipart,
|
mut multipart: axum::extract::Multipart,
|
||||||
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
) -> 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);
|
let size_bytes = tokio::fs::metadata(&storage_path).await.map(|m| m.len() as i64).unwrap_or(0);
|
||||||
|
|
||||||
sqlx::query(
|
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(asset_id)
|
||||||
.bind(&filename)
|
.bind(&filename)
|
||||||
.bind(storage_path)
|
.bind(storage_path)
|
||||||
.bind(mimetype)
|
.bind(mimetype)
|
||||||
.bind(size_bytes)
|
.bind(size_bytes)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -564,6 +579,7 @@ pub struct AuthPayload {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
pub full_name: Option<String>,
|
pub full_name: Option<String>,
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
|
pub organization_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(
|
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 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());
|
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>(
|
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(&payload.email)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.bind(full_name)
|
.bind(full_name)
|
||||||
.bind(&role)
|
.bind(&role)
|
||||||
.fetch_one(&pool)
|
.bind(organization.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
.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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
@@ -615,7 +650,7 @@ pub async fn login(
|
|||||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
@@ -629,39 +664,20 @@ pub async fn login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
pub async fn get_course_analytics(
|
pub async fn get_course_analytics(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
headers: HeaderMap,
|
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
||||||
// 1. Extract and verify token
|
// 1. Fetch Course to check ownership
|
||||||
let auth_header = headers.get("Authorization")
|
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.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")
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||||
|
|
||||||
// 3. Enforce RBAC
|
// 2. Enforce RBAC
|
||||||
if claims.role != "admin" && course.instructor_id != claims.sub {
|
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()));
|
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(
|
pub async fn get_audit_logs(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
headers: HeaderMap,
|
|
||||||
Query(query): Query<AuditQuery>,
|
Query(query): Query<AuditQuery>,
|
||||||
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (StatusCode, String)> {
|
||||||
// 1. Auth check
|
// 1. RBAC check
|
||||||
let auth_header = headers.get("Authorization")
|
if claims.role != "admin" {
|
||||||
.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" {
|
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins can view audit logs".into()));
|
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 limit = query.limit.unwrap_or(50);
|
||||||
let offset = (query.page.unwrap_or(1) - 1) * limit;
|
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
|
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
|
FROM audit_logs a
|
||||||
LEFT JOIN users u ON a.user_id = u.id
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.organization_id = $3
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post, delete},
|
routing::{get, post, delete, put},
|
||||||
Router,
|
Router,
|
||||||
|
middleware,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
@@ -33,28 +34,34 @@ async fn main() {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(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", 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}/publish", post(handlers::publish_course))
|
||||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
||||||
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
||||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
.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("/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", 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("/courses/{id}/grading", get(handlers::get_grading_categories))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/assets/upload", post(handlers::upload_asset))
|
.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"))
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
|
.merge(protected_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
|
||||||
tracing::info!("CMS Service listening on {}", addr);
|
tracing::info!("CMS Service listening on {}", addr);
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
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,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics};
|
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics, Organization};
|
||||||
use common::auth::create_jwt;
|
use common::auth::{create_jwt, Claims};
|
||||||
|
use common::middleware::Org;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -12,18 +13,20 @@ use bcrypt::{hash, verify, DEFAULT_COST};
|
|||||||
|
|
||||||
pub async fn enroll_user(
|
pub async fn enroll_user(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Enrollment>, StatusCode> {
|
) -> 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_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 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 = claims.sub;
|
||||||
let user_id = Uuid::parse_str(user_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
let enrollment = sqlx::query_as::<_, Enrollment>(
|
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(user_id)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -39,6 +42,7 @@ pub struct AuthPayload {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub full_name: Option<String>,
|
pub full_name: Option<String>,
|
||||||
|
pub organization_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[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());
|
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>(
|
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(&payload.email)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.bind(full_name)
|
.bind(full_name)
|
||||||
.fetch_one(&pool)
|
.bind(organization.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
.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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
@@ -97,7 +120,7 @@ pub async fn login(
|
|||||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
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()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
@@ -129,9 +152,10 @@ pub async fn ingest_course(
|
|||||||
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 1. Upsert Course
|
// 1. Upsert Course
|
||||||
|
let org_id = payload.course.organization_id;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at)
|
"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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
@@ -140,7 +164,8 @@ pub async fn ingest_course(
|
|||||||
end_date = EXCLUDED.end_date,
|
end_date = EXCLUDED.end_date,
|
||||||
passing_percentage = EXCLUDED.passing_percentage,
|
passing_percentage = EXCLUDED.passing_percentage,
|
||||||
certificate_template = EXCLUDED.certificate_template,
|
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.id)
|
||||||
.bind(&payload.course.title)
|
.bind(&payload.course.title)
|
||||||
@@ -151,6 +176,7 @@ pub async fn ingest_course(
|
|||||||
.bind(payload.course.passing_percentage)
|
.bind(payload.course.passing_percentage)
|
||||||
.bind(&payload.course.certificate_template)
|
.bind(&payload.course.certificate_template)
|
||||||
.bind(payload.course.updated_at)
|
.bind(payload.course.updated_at)
|
||||||
|
.bind(org_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -174,8 +200,8 @@ pub async fn ingest_course(
|
|||||||
// 3. Insert Grading Categories
|
// 3. Insert Grading Categories
|
||||||
for cat in payload.grading_categories {
|
for cat in payload.grading_categories {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at)
|
"INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at, organization_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||||
)
|
)
|
||||||
.bind(cat.id)
|
.bind(cat.id)
|
||||||
.bind(payload.course.id)
|
.bind(payload.course.id)
|
||||||
@@ -183,6 +209,7 @@ pub async fn ingest_course(
|
|||||||
.bind(cat.weight)
|
.bind(cat.weight)
|
||||||
.bind(cat.drop_count)
|
.bind(cat.drop_count)
|
||||||
.bind(cat.created_at)
|
.bind(cat.created_at)
|
||||||
|
.bind(org_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -191,22 +218,23 @@ pub async fn ingest_course(
|
|||||||
// 4. Insert Modules and Lessons
|
// 4. Insert Modules and Lessons
|
||||||
for pub_module in payload.modules {
|
for pub_module in payload.modules {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO modules (id, course_id, title, position, created_at)
|
"INSERT INTO modules (id, course_id, title, position, created_at, organization_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)"
|
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||||
)
|
)
|
||||||
.bind(pub_module.module.id)
|
.bind(pub_module.module.id)
|
||||||
.bind(payload.course.id)
|
.bind(payload.course.id)
|
||||||
.bind(&pub_module.module.title)
|
.bind(&pub_module.module.title)
|
||||||
.bind(pub_module.module.position)
|
.bind(pub_module.module.position)
|
||||||
.bind(pub_module.module.created_at)
|
.bind(pub_module.module.created_at)
|
||||||
|
.bind(org_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
for lesson in pub_module.lessons {
|
for lesson in pub_module.lessons {
|
||||||
sqlx::query(
|
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)
|
"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)"
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"
|
||||||
)
|
)
|
||||||
.bind(lesson.id)
|
.bind(lesson.id)
|
||||||
.bind(pub_module.module.id)
|
.bind(pub_module.module.id)
|
||||||
@@ -221,6 +249,7 @@ pub async fn ingest_course(
|
|||||||
.bind(lesson.grading_category_id)
|
.bind(lesson.grading_category_id)
|
||||||
.bind(lesson.max_attempts)
|
.bind(lesson.max_attempts)
|
||||||
.bind(lesson.allow_retry)
|
.bind(lesson.allow_retry)
|
||||||
|
.bind(org_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -236,19 +265,22 @@ pub async fn ingest_course(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_course_outline(
|
pub async fn get_course_outline(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
||||||
// 1. Fetch Course
|
// 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Fetch Modules
|
// 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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"
|
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -265,8 +298,9 @@ pub async fn get_course_outline(
|
|||||||
// 4. Fetch Lessons
|
// 4. Fetch Lessons
|
||||||
let mut pub_modules = Vec::new();
|
let mut pub_modules = Vec::new();
|
||||||
for module in modules {
|
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(module.id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -285,11 +319,13 @@ pub async fn get_course_outline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_lesson_content(
|
pub async fn get_lesson_content(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> 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(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -298,11 +334,13 @@ pub async fn get_lesson_content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_enrollments(
|
pub async fn get_user_enrollments(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
) -> 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(user_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -311,12 +349,14 @@ pub async fn get_user_enrollments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn submit_lesson_score(
|
pub async fn submit_lesson_score(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<GradeSubmissionPayload>,
|
Json(payload): Json<GradeSubmissionPayload>,
|
||||||
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
||||||
// 1. Get lesson attempt rules
|
// 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(payload.lesson_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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();
|
let max_attempts = max_attempts.flatten();
|
||||||
|
|
||||||
// 2. Check existing grade/attempts
|
// 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.user_id)
|
||||||
.bind(payload.lesson_id)
|
.bind(payload.lesson_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -345,8 +386,8 @@ pub async fn submit_lesson_score(
|
|||||||
|
|
||||||
// 3. Upsert with increment
|
// 3. Upsert with increment
|
||||||
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
||||||
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count)
|
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count, organization_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, 1)
|
VALUES ($1, $2, $3, $4, $5, 1, $6)
|
||||||
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
|
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
|
||||||
score = EXCLUDED.score,
|
score = EXCLUDED.score,
|
||||||
metadata = EXCLUDED.metadata,
|
metadata = EXCLUDED.metadata,
|
||||||
@@ -359,6 +400,7 @@ pub async fn submit_lesson_score(
|
|||||||
.bind(payload.lesson_id)
|
.bind(payload.lesson_id)
|
||||||
.bind(payload.score)
|
.bind(payload.score)
|
||||||
.bind(payload.metadata)
|
.bind(payload.metadata)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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(
|
pub async fn get_user_course_grades(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
||||||
let grades = sqlx::query_as::<_, common::models::UserGrade>(
|
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(user_id)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -382,19 +426,22 @@ pub async fn get_user_course_grades(
|
|||||||
Ok(Json(grades))
|
Ok(Json(grades))
|
||||||
}
|
}
|
||||||
pub async fn get_course_analytics(
|
pub async fn get_course_analytics(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(course_id): Path<Uuid>,
|
Path(course_id): Path<Uuid>,
|
||||||
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
||||||
// 1. Total Enrollments
|
// 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(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 2. Average Course Score (Overall)
|
// 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(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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
|
COUNT(g.id) as submission_count
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
LEFT JOIN user_grades g ON l.id = g.lesson_id
|
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
|
GROUP BY l.id, l.title, l.position
|
||||||
ORDER BY l.position
|
ORDER BY l.position
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post, put},
|
||||||
Router,
|
Router,
|
||||||
|
middleware,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
@@ -33,23 +34,27 @@ async fn main() {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
let app = Router::new()
|
let protected_routes = Router::new()
|
||||||
.route("/catalog", get(handlers::get_course_catalog))
|
|
||||||
.route("/enroll", post(handlers::enroll_user))
|
.route("/enroll", post(handlers::enroll_user))
|
||||||
.route("/ingest", post(handlers::ingest_course))
|
.route("/enrollments/:id", get(handlers::get_user_enrollments))
|
||||||
.route("/auth/register", post(handlers::register))
|
|
||||||
.route("/auth/login", post(handlers::login))
|
|
||||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
|
||||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
.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("/grades", post(handlers::submit_lesson_score))
|
||||||
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
||||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
.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)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||||
tracing::info!("LMS Service listening on {}", addr);
|
tracing::info!("LMS Service listening on {}", addr);
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
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
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
axum = { workspace = true, features = ["macros"] }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ use chrono::{Utc, Duration};
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: Uuid,
|
pub sub: Uuid,
|
||||||
|
pub org: Uuid,
|
||||||
pub exp: i64,
|
pub exp: i64,
|
||||||
pub role: String,
|
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()
|
let expiration = Utc::now()
|
||||||
.checked_add_signed(Duration::hours(24))
|
.checked_add_signed(Duration::hours(24))
|
||||||
.expect("valid timestamp")
|
.expect("valid timestamp")
|
||||||
@@ -18,6 +19,7 @@ pub fn create_jwt(user_id: Uuid, role: &str) -> Result<String, jsonwebtoken::err
|
|||||||
|
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
|
org: organization_id,
|
||||||
exp: expiration,
|
exp: expiration,
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod utils;
|
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)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Course {
|
pub struct Course {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub instructor_id: Uuid,
|
pub instructor_id: Uuid,
|
||||||
@@ -68,6 +69,7 @@ pub struct UserGrade {
|
|||||||
pub struct AuditLog {
|
pub struct AuditLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub entity_type: String,
|
pub entity_type: String,
|
||||||
pub entity_id: Uuid,
|
pub entity_id: Uuid,
|
||||||
@@ -91,6 +93,7 @@ pub struct AuditLogResponse {
|
|||||||
pub struct Enrollment {
|
pub struct Enrollment {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub course_id: Uuid,
|
pub course_id: Uuid,
|
||||||
pub enroled_at: DateTime<Utc>,
|
pub enroled_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -98,6 +101,7 @@ pub struct Enrollment {
|
|||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Asset {
|
pub struct Asset {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub storage_path: String,
|
pub storage_path: String,
|
||||||
pub mimetype: String,
|
pub mimetype: String,
|
||||||
@@ -108,6 +112,7 @@ pub struct Asset {
|
|||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { lmsApi } from "@/lib/api";
|
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() {
|
export default function ExperienceLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [organizationName, setOrganizationName] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -37,7 +38,8 @@ export default function ExperienceLoginPage() {
|
|||||||
const response = await lmsApi.register({
|
const response = await lmsApi.register({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
full_name: fullName
|
full_name: fullName,
|
||||||
|
organization_name: organizationName,
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem("experience_token", response.token);
|
localStorage.setItem("experience_token", response.token);
|
||||||
@@ -101,6 +103,24 @@ export default function ExperienceLoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-300 mb-2">
|
<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 { useAuth } from "@/context/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [organizationName, setOrganizationName] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
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);
|
login(res.user, res.token);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -97,6 +98,21 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface AuthPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
|
organization_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Enrollment {
|
export interface Enrollment {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { cmsApi } from "@/lib/api";
|
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() {
|
export default function StudioLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [organizationName, setOrganizationName] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -30,19 +33,18 @@ export default function StudioLoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("studio_token", response.token);
|
login(response.user, response.token);
|
||||||
localStorage.setItem("studio_user", JSON.stringify(response.user));
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
const response = await cmsApi.register({
|
const response = await cmsApi.register({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
role: "instructor"
|
role: "instructor",
|
||||||
|
organization_name: organizationName,
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem("studio_token", response.token);
|
login(response.user, response.token);
|
||||||
localStorage.setItem("studio_user", JSON.stringify(response.user));
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -85,6 +87,7 @@ export default function StudioLoginPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{!isLogin && (
|
{!isLogin && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-300 mb-2">
|
<label className="block text-sm font-bold text-gray-300 mb-2">
|
||||||
Full Name
|
Full Name
|
||||||
@@ -101,6 +104,25 @@ export default function StudioLoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { cmsApi } from "@/lib/api";
|
|||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [organizationName, setOrganizationName] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
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);
|
login(res.user, res.token);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -97,6 +98,21 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -3,48 +3,65 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--foreground-rgb: 255, 255, 255;
|
--foreground-rgb: 229, 231, 235;
|
||||||
--background-start-rgb: 10, 10, 20;
|
/* text-gray-200 */
|
||||||
|
--background-start-rgb: 15, 17, 21;
|
||||||
|
/* #0f1115 */
|
||||||
--background-end-rgb: 0, 0, 0;
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
|
||||||
--accent-primary: #3b82f6;
|
--accent-primary: #3b82f6;
|
||||||
--accent-secondary: #8b5cf6;
|
/* blue-500 */
|
||||||
--glass-bg: rgba(255, 255, 255, 0.05);
|
--accent-secondary: #6366f1;
|
||||||
--glass-border: rgba(255, 255, 255, 0.1);
|
/* indigo-500 */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-blur: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: rgb(var(--foreground-rgb));
|
color: rgb(var(--foreground-rgb));
|
||||||
background: linear-gradient(
|
background: var(--background-start-rgb);
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgb(var(--background-end-rgb))
|
|
||||||
)
|
|
||||||
rgb(var(--background-start-rgb));
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: var(--glass-blur);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-text {
|
.glass-card {
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
@apply glass rounded-2xl p-6;
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-premium {
|
.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));
|
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 {
|
.btn-premium:hover {
|
||||||
@apply scale-105;
|
@apply scale-[1.02] -translate-y-0.5 shadow-blue-500/40;
|
||||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
}
|
||||||
|
|
||||||
|
.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 type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
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"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenCCB Studio | modern Course Management",
|
title: "OpenCCB | Studio",
|
||||||
description: "Advanced LMS Content Management System inspired by Open edX",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -17,24 +43,18 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<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>
|
<AuthProvider>
|
||||||
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
|
<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">
|
||||||
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
<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">
|
||||||
<h1 className="text-xl font-bold tracking-tight">
|
<BookOpen size={20} />
|
||||||
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" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
|
||||||
</nav>
|
</Link>
|
||||||
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
|
<AuthHeader />
|
||||||
{children}
|
</header>
|
||||||
</main>
|
<main className="flex-1">{children}</main>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+41
-91
@@ -1,137 +1,87 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { cmsApi, Course } from "@/lib/api";
|
import { cmsApi, Course } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Plus, BookOpen } from "lucide-react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function StudioDashboard() {
|
||||||
const router = useRouter();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const { user } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const loadCourses = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
const data = await cmsApi.getCourses();
|
const data = await cmsApi.getCourses();
|
||||||
setCourses(data);
|
setCourses(data);
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch courses:", err);
|
console.error("Failed to load courses", err);
|
||||||
setError("Could not connect to CMS service. showing offline mode.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCourses();
|
loadCourses();
|
||||||
}, [router]);
|
}, [user]);
|
||||||
|
|
||||||
|
|
||||||
const handleCreateCourse = async () => {
|
const handleCreateCourse = async () => {
|
||||||
const title = prompt("Enter course title:");
|
const title = prompt("Enter new course title:");
|
||||||
if (!title) return;
|
if (title) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCourse = await cmsApi.createCourse(title);
|
const newCourse = await cmsApi.createCourse(title);
|
||||||
setCourses([...courses, newCourse]);
|
setCourses(prev => [...prev, newCourse]);
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert("Failed to create course. Is the backend running?");
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="p-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<h1 className="text-3xl font-bold">My Courses</h1>
|
||||||
<h2 className="text-3xl font-bold">My Courses</h2>
|
<button onClick={handleCreateCourse} className="btn-premium flex items-center gap-2">
|
||||||
<p className="text-gray-400">Manage and create your learning content</p>
|
<Plus size={18} />
|
||||||
</div>
|
New Course
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="col-span-full py-20 text-center text-gray-500">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
|
{[1, 2, 3].map(i => (
|
||||||
<p>Loading your courses...</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{displayCourses.map((course) => (
|
{courses.map(course => (
|
||||||
<Link href={`/courses/${course.id}`} key={course.id}>
|
<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="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
|
||||||
<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">
|
<div className="flex-1">
|
||||||
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
|
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-white/5">
|
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
|
||||||
<span suppressHydrationWarning className="text-xs text-gray-500">
|
</div>
|
||||||
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500">
|
||||||
</span>
|
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span>
|
||||||
<span className="text-xs font-medium text-blue-400">View Details →</span>
|
<span>ID: {course.id.slice(0, 4)}...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
</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 {
|
export interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
instructor_id: string;
|
instructor_id: string;
|
||||||
passing_percentage: number;
|
passing_percentage: number;
|
||||||
certificate_template?: string;
|
certificate_template?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
updated_at: string;
|
||||||
|
modules?: Module[];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Module {
|
export interface Module {
|
||||||
@@ -29,7 +17,6 @@ export interface Module {
|
|||||||
course_id: string;
|
course_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
position: number;
|
position: number;
|
||||||
created_at: string;
|
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +27,9 @@ export interface Block {
|
|||||||
content?: string;
|
content?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
media_type?: 'video' | 'audio';
|
media_type?: 'video' | 'audio';
|
||||||
config?: {
|
config?: any;
|
||||||
maxPlays?: number;
|
|
||||||
currentPlays?: number;
|
|
||||||
allowDownload?: boolean;
|
|
||||||
};
|
|
||||||
quiz_data?: {
|
quiz_data?: {
|
||||||
questions: {
|
questions: any[];
|
||||||
id: string;
|
|
||||||
question: string;
|
|
||||||
options: string[];
|
|
||||||
correct: number[];
|
|
||||||
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
pairs?: { left: string; right: string }[];
|
pairs?: { left: string; right: string }[];
|
||||||
items?: string[];
|
items?: string[];
|
||||||
@@ -65,31 +42,16 @@ export interface Lesson {
|
|||||||
module_id: string;
|
module_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
content_url: string | null;
|
|
||||||
transcription?: {
|
|
||||||
en?: string;
|
|
||||||
es?: string;
|
|
||||||
cues?: { start: number; end: number; text: string }[];
|
|
||||||
} | null;
|
|
||||||
metadata?: {
|
metadata?: {
|
||||||
blocks?: Block[];
|
blocks: Block[];
|
||||||
} | null;
|
};
|
||||||
is_graded: boolean;
|
is_graded: boolean;
|
||||||
grading_category_id: string | null;
|
grading_category_id: string | null;
|
||||||
max_attempts: number | null;
|
max_attempts: number | null;
|
||||||
allow_retry: boolean;
|
allow_retry: boolean;
|
||||||
position: number;
|
transcription?: any;
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GradingCategory {
|
|
||||||
id: string;
|
|
||||||
course_id: string;
|
|
||||||
name: string;
|
|
||||||
weight: number;
|
|
||||||
drop_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -106,200 +68,114 @@ export interface AuthPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
full_name?: 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 {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_full_name?: string;
|
user_full_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
entity_type: string;
|
entity_type: string;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
changes: any;
|
changes: any;
|
||||||
created_at: string;
|
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 = {
|
export const cmsApi = {
|
||||||
async getCourses(): Promise<Course[]> {
|
// Auth
|
||||||
const response = await fetch(`${API_BASE_URL}/courses`);
|
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
if (!response.ok) throw new Error('Failed to fetch courses');
|
login: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
async createCourse(title: string): Promise<Course> {
|
// Courses
|
||||||
const response = await fetch(`${API_BASE_URL}/courses`, {
|
getCourses: (): Promise<Course[]> => apiFetch('/courses'),
|
||||||
method: 'POST',
|
createCourse: (title: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
|
||||||
body: JSON.stringify({ title }),
|
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) }),
|
||||||
if (!response.ok) throw new Error('Failed to create course');
|
publishCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST' }),
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCourseWithFullOutline(courseId: string): Promise<Course & { modules: Module[] }> {
|
// Modules & Lessons
|
||||||
const course = await fetch(`${API_BASE_URL}/courses/${courseId}`).then(res => res.json());
|
createModule: (course_id: string, title: string, position: number): Promise<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
|
||||||
const modules = await fetch(`${API_BASE_URL}/modules?course_id=${courseId}`).then(res => res.json());
|
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) => {
|
// Grading
|
||||||
const lessons = await fetch(`${API_BASE_URL}/lessons?module_id=${m.id}`).then(res => res.json());
|
getGradingCategories: (courseId: string): Promise<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
|
||||||
return { ...m, lessons };
|
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> {
|
// Assets
|
||||||
const response = await fetch(`${API_BASE_URL}/modules`, {
|
uploadAsset: (file: File): Promise<UploadResponse> => {
|
||||||
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 }> {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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',
|
method: 'POST',
|
||||||
|
headers,
|
||||||
body: formData,
|
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