feat: Implement comprehensive course analytics, RBAC with roles and authentication, and dynamic passing thresholds.

This commit is contained in:
2025-12-23 10:12:53 -03:00
parent f592f78b6c
commit 72ddb43fd7
29 changed files with 1433 additions and 231 deletions
View File
View File
View File
+186 -48
View File
@@ -4,11 +4,12 @@ OpenCCB is a high-performance, microservices-based Learning Management System (L
## Architecture
- **CMS Service (Port 3001)**: Course management, content creation, and administrative configurations.
- **LMS Service (Port 3002)**: Student experience, course consumption, and enrollment.
- **Shared Library**: Core models and authentication logic.
- **Database**: PostgreSQL (shared/isolated schemas).
- **Studio (Frontend)**: Next.js application with a block-based **Activity Builder** for instructors.
- **CMS Service (Port 3001)**: Course management, content creation, grading policies, and administrative configurations.
- **LMS Service (Port 3002)**: Student experience, course consumption, enrollment, and grade tracking.
- **Shared Library**: Core models, authentication logic, and cross-service data contracts.
- **Database**: PostgreSQL with separate databases for CMS and LMS.
- **Studio (Frontend)**: Next.js application for instructors with block-based Activity Builder.
- **Experience (Frontend)**: Next.js student portal with interactive lesson player and progress dashboard.
- **Asset Storage**: Persistent local storage for native video/audio uploads.
## Getting Started
@@ -24,67 +25,204 @@ OpenCCB is a high-performance, microservices-based Learning Management System (L
docker compose up -d --build
```
### Access Points
- **Studio (Instructors)**: http://localhost:3000
- **Experience (Students)**: http://localhost:3003
- **CMS API**: http://localhost:3001
- **LMS API**: http://localhost:3002
## Core Features
### 🎨 Content Creation & Management
- **Block-Based Activity Builder**: Create rich lessons using text, media, and interactive assessment blocks
- **Advanced Assessment Types**:
- Multiple Choice & True/False
- Fill-in-the-Blanks
- Matching Pairs
- Ordering/Sequencing
- Short Answer (with configurable correct answers)
- **Native File Uploads**: Drag-and-drop video/audio uploads with persistent storage
- **Playback Constraints**: Limit media views per student
- **Dynamic Content Reordering**: Organize blocks with move up/down controls
- **Course Settings**: Configure passing percentages and grading criteria
### 📊 Advanced Grading System
- **Holistic Grading Policy**:
- Create weighted grading categories (e.g., Homework 30%, Exams 70%)
- Drop lowest N scores per category
- Automatic weighted grade calculation
- **Configurable Assessment Policies**:
- Set maximum attempts per lesson (1-10 or unlimited)
- Enable/disable instant corrections and retries
- Atomic attempt tracking with enforcement
- **Dynamic Passing Thresholds**:
- Instructors set custom passing percentages (0-100%)
- 5-tier performance visualization for students:
- **Reprobado (Red)**: 0% to P-1%
- **Rendimiento Bajo (Orange)**: P% to P+9%
- **Rendimiento Medio (Yellow)**: P+10% to P+15%
- **Buen Rendimiento (Green)**: P+16% to 90%
- **Excelente (Blue)**: 91%+
### 📈 Analytics & Insights
- **Instructor Analytics Dashboard**:
- Total enrollments per course
- Overall average score across all assessments
- Per-lesson performance breakdown
- Automatic detection of "struggling lessons" (avg score < 70%)
- Visual performance charts
- **Student Progress Dashboard**:
- Real-time weighted grade calculation
- Category-by-category breakdown
- Interactive performance bar with tier visualization
- Lesson completion tracking
### 🔐 Authentication & Security
- **JWT-Based Authentication**: Secure token-based auth across all services
- **Role-Based Access Control (RBAC)**:
- **Administrators**: Full platform access, global analytics, all course management
- **Instructors**: Course creation, analytics for assigned courses only
- **Students**: Course enrollment, lesson consumption, progress tracking
- **Service-to-Service Authorization**: Secure internal API calls with token validation
- **Audit Logging**: All CMS mutations recorded for compliance and debugging
### 🚀 Service Integration
- **Automatic Sync**: One-click publish from CMS to LMS
- **Cross-Service Data Flow**: Courses, modules, lessons, and grading policies synchronized
- **Real-Time Updates**: Student progress immediately reflected in analytics
## API Documentation
### CMS Service (`:3001`)
#### Create a Course
- **URL**: `/courses`
- **Method**: `POST`
- **Example**:
#### Authentication
```bash
# Register (Instructor/Admin)
curl -X POST http://localhost:3001/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "instructor@example.com", "password": "secure123", "full_name": "John Doe", "role": "instructor"}'
# Login
curl -X POST http://localhost:3001/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "instructor@example.com", "password": "secure123"}'
```
#### Course Management
```bash
# Create Course
curl -X POST http://localhost:3001/courses \
-H "Content-Type: application/json" \
-d '{"title": "Advanced Rust 2024"}'
```
- **Response**:
```json
{
"id": "uuid-v4",
"title": "Advanced Rust 2024",
"description": null,
"instructor_id": "uuid-v4",
"start_date": null,
"end_date": null,
"created_at": "2023-12-19T10:00:00Z",
"updated_at": "2023-12-19T10:00:00Z"
}
```
#### Create a Module
- **URL**: `/modules`
- **Method**: `POST`
- **Example**:
```bash
curl -X POST http://localhost:3001/modules \
# Update Course Settings
curl -X PUT http://localhost:3001/courses/{id} \
-H "Content-Type: application/json" \
-d '{"title": "Introduction", "course_id": "YOUR_COURSE_ID", "position": 1}'
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"passing_percentage": 75}'
# Publish Course to LMS
curl -X POST http://localhost:3001/courses/{id}/publish \
-H "Authorization: Bearer YOUR_TOKEN"
# Get Course Analytics (RBAC enforced)
curl http://localhost:3001/courses/{id}/analytics \
-H "Authorization: Bearer YOUR_TOKEN"
```
### LMS Service (`:3002`)
#### Get Course Catalog
- **URL**: `/catalog`
- **Method**: `GET`
- **Example**:
#### Student Operations
```bash
curl http://localhost:3002/catalog
```
# Register Student
curl -X POST http://localhost:3002/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "student@example.com", "password": "secure123", "full_name": "Jane Smith"}'
#### Enroll in a Course
- **URL**: `/enroll`
- **Method**: `POST`
- **Example**:
```bash
# Get Course Catalog
curl http://localhost:3002/catalog
# Enroll in Course
curl -X POST http://localhost:3002/enroll \
-H "Content-Type: application/json" \
-d '{"course_id": "YOUR_COURSE_ID"}'
-d '{"user_id": "USER_UUID", "course_id": "COURSE_UUID"}'
# Submit Lesson Score
curl -X POST http://localhost:3002/grades \
-H "Content-Type: application/json" \
-d '{"user_id": "USER_UUID", "lesson_id": "LESSON_UUID", "score": 0.85}'
# Get Student Grades
curl http://localhost:3002/users/{user_id}/courses/{course_id}/grades
```
Every mutation in the CMS (Create Course/Module/Lesson) is automatically recorded in the `audit_logs` table for compliance and debugging.
## Technology Stack
## Features
- **Block-Based Activity Builder**: Create lessons using text, media, and interactive quiz blocks.
- **Native File Uploads**: Drag-and-drop video/audio uploads with persistence.
- **Playback Constraints**: Limit how many times students can view specific media items.
- **Dynamic Reordering**: (Coming Soon) Organize content blocks with a single click.
### Backend
- **Rust 2024**: High-performance, memory-safe backend services
- **Axum 0.8**: Modern async web framework
- **SQLx**: Compile-time verified SQL queries
- **PostgreSQL**: Robust relational database
- **JWT**: Secure authentication tokens
### Frontend
- **Next.js 14**: React framework with App Router
- **TypeScript**: Type-safe frontend development
- **Tailwind CSS**: Utility-first styling
- **Lucide React**: Modern icon library
### DevOps
- **Docker**: Containerized deployment
- **Docker Compose**: Multi-service orchestration
## Project Structure
```
openccb/
├── services/
│ ├── cms-service/ # Course management backend
│ │ ├── src/
│ │ │ ├── handlers.rs # API handlers
│ │ │ └── main.rs # Service entry point
│ │ └── migrations/ # Database migrations
│ └── lms-service/ # Learning management backend
│ ├── src/
│ │ ├── handlers.rs # API handlers
│ │ └── main.rs # Service entry point
│ └── migrations/ # Database migrations
├── shared/
│ └── common/ # Shared models and auth
│ └── src/
│ ├── models.rs # Data models
│ └── auth.rs # JWT utilities
├── web/
│ ├── studio/ # Instructor frontend
│ │ └── src/
│ │ ├── app/ # Next.js pages
│ │ ├── components/ # React components
│ │ └── lib/ # API client
│ └── experience/ # Student frontend
│ └── src/
│ ├── app/ # Next.js pages
│ ├── components/ # React components
│ └── lib/ # API client
└── docker-compose.yml # Service orchestration
```
## Recent Enhancements
### December 2024
-**Holistic Grading System**: Weighted categories, drop policies, and automatic calculation
-**Attempt Tracking**: Configurable max attempts and retry policies per lesson
-**Instructor Analytics**: Course-level insights with RBAC enforcement
-**Dynamic Passing Thresholds**: Customizable pass marks with 5-tier performance visualization
-**Role-Based Access Control**: Admin, Instructor, and Student roles with granular permissions
-**Enhanced Progress Dashboard**: Real-time weighted grades and visual performance bars
## Contributing
Contributions are welcome! Please ensure all tests pass and follow the existing code style.
## License
MIT License - see LICENSE file for details.
View File
View File
View File
+122 -29
View File
@@ -1,35 +1,128 @@
# OpenCCB: Open Comprehensive Course Backbone - Roadmap
## Phase 1: Foundation (Current)
- [x] Rust Workspace Setup (Edition 2024).
- [x] Microservices Scaffolding (CMS & LMS).
- [x] Multi-Database Infrastructure (Postgres with separate DBs).
- [x] Frontend Initialization (Next.js Studio).
- [x] Dockerization of all services.
- [x] API Integration (Dashboard <-> CMS Service).
## Phase 1: Foundation
- [x] Rust Workspace Setup (Edition 2024)
- [x] Microservices Scaffolding (CMS & LMS)
- [x] Multi-Database Infrastructure (PostgreSQL with separate DBs)
- [x] Frontend Initialization (Next.js Studio & Experience)
- [x] Dockerization of all services
- [x] API Integration (Dashboard <-> CMS Service)
## Phase 2: Core CMS Features (Current Focus)
- [/] Course Outline Editor (Modules & Lessons).
- [x] File Upload System (Video/Audio/Native Assets).
- [/] Interactive Content (**Activity Builder Refinement**).
- [ ] Block Reordering (Move Up/Down).
- [ ] Rich Text Editor Integration.
- [ ] Quiz Refinements (True/False, Multi-Response).
- [ ] Service-to-Service Communication (CMS -> LMS sync).
- [x] **Video Player**: Integrated premium video player with playback limits.
- [ ] **Full Studio UI**: Drag-and-drop course builder.
## Phase 2: Core CMS Features
- [x] Course Outline Editor (Modules & Lessons)
- [x] File Upload System (Video/Audio/Native Assets)
- [x] Interactive Content (Activity Builder)
- [x] Block Reordering (Move Up/Down)
- [x] Rich Text Descriptions
- [x] Media Blocks with Playback Constraints
- [x] Quiz Blocks (Multiple Choice, True/False, Multiple Select)
- [x] Advanced Assessment Types:
- [x] Fill-in-the-Blanks
- [x] Matching Pairs
- [x] Ordering/Sequencing
- [x] Short Answer
- [x] Service-to-Service Communication (CMS -> LMS sync)
- [x] Premium Video Player with playback limits
- [x] Full Studio UI with dynamic course management
## Phase 3: Authentication & Security
- [ ] **Auth Service**: Integrated OIDC/OAuth2 or custom JWT provider.
- [ ] **RBAC**: Role-Based Access Control (Admin, Instructor, Student).
- [ ] **Audit UI**: Admin interface to view audit logs.
## Phase 3: Authentication & Security
- [x] **JWT-Based Authentication**: Common auth across all services
- [x] **Role-Based Access Control (RBAC)**:
- [x] Multi-role support (Admin, Instructor, Student)
- [x] Role-specific permissions and UI
- [x] Token-based authorization for protected endpoints
- [x] **Audit Logging**: All CMS mutations tracked
- [ ] **Audit UI**: Admin interface to view audit logs
## Phase 4: LMS Experience
- [ ] **Progress Tracking**: Track student completion of lessons and modules.
- [ ] **Certificates**: Automated certificate generation upon completion.
- [ ] **Mobile Responsive**: Optimize student interface for mobile devices.
## Phase 4: LMS Experience & Grading ✅
- [x] **Student Portal (Experience)**:
- [x] Course catalog and enrollment
- [x] Interactive lesson player
- [x] Mobile-responsive design
- [x] **Holistic Grading System**:
- [x] Weighted grading categories
- [x] Drop lowest N scores per category
- [x] Automatic weighted grade calculation
- [x] **Assessment Policies**:
- [x] Configurable max attempts per lesson
- [x] Instant corrections and retry policies
- [x] Atomic attempt tracking with enforcement
- [x] **Progress Tracking**:
- [x] Real-time score visualization
- [x] Category-by-category breakdown
- [x] Weighted grade calculation
- [x] **Dynamic Passing Thresholds**:
- [x] Configurable passing percentage per course
- [x] 5-tier performance visualization
- [x] Color-coded feedback (Reprobado to Excelente)
- [ ] **Certificates**: Automated certificate generation upon completion
## Phase 5: Advanced Features
- [ ] **Multi-tenancy**: Support for multiple organizations.
- [ ] **Analytics**: Insight dashboards for instructors.
- [ ] **AI Integration**: AI-driven lesson summaries and quiz generation.
## Phase 5: Analytics & Insights ✅
- [x] **Instructor Analytics Dashboard**:
- [x] Total enrollments per course
- [x] Overall average score
- [x] Per-lesson performance breakdown
- [x] "Struggling lessons" detection
- [x] RBAC enforcement (instructors see only their courses)
- [x] **Student Progress Dashboard**:
- [x] Interactive performance bar
- [x] Tier-based feedback visualization
- [x] Real-time grade updates
## Phase 6: Advanced Features (Planned)
- [ ] **Multi-tenancy**: Support for multiple organizations
- [ ] **Advanced Analytics**:
- [ ] Cohort analysis
- [ ] Retention metrics
- [ ] Engagement heatmaps
- [ ] **AI Integration**:
- [ ] AI-driven lesson summaries
- [ ] Automated quiz generation
- [ ] Personalized learning paths
- [ ] **Gamification**:
- [ ] Badges and achievements
- [ ] Leaderboards
- [ ] XP and leveling system
- [ ] **Communication**:
- [ ] Discussion forums
- [ ] Direct messaging
- [ ] Announcements
- [ ] **Content Library**:
- [ ] Reusable content blocks
- [ ] Template courses
- [ ] Shared resource pool
## Phase 7: Enterprise Features (Future)
- [ ] **Advanced Reporting**:
- [ ] Custom report builder
- [ ] Export to PDF/CSV
- [ ] Scheduled reports
- [ ] **Integration Ecosystem**:
- [ ] LTI 1.3 support
- [ ] SCORM compliance
- [ ] Third-party integrations (Zoom, Google Meet)
- [ ] **Mobile Apps**:
- [ ] Native iOS app
- [ ] Native Android app
- [ ] Offline mode
- [ ] **Accessibility**:
- [ ] WCAG 2.1 AA compliance
- [ ] Screen reader optimization
- [ ] Keyboard navigation
## Current Status
**Platform Maturity**: Production-ready for core LMS/CMS functionality
**Recent Milestones** (December 2024):
- ✅ Holistic grading system with weighted categories
- ✅ Configurable assessment policies (attempts, retries)
- ✅ Instructor analytics with RBAC
- ✅ Dynamic passing thresholds with 5-tier visualization
- ✅ Enhanced student progress dashboard
**Next Priorities**:
1. Automated certificate generation
2. Audit log UI for administrators
3. Multi-tenancy support
4. AI-powered content generation
@@ -0,0 +1,8 @@
-- Add role column to users table for RBAC
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'instructor';
-- Add check constraint to ensure only valid roles are used
ALTER TABLE users ADD CONSTRAINT check_valid_role CHECK (role IN ('admin', 'instructor', 'student'));
-- Note: In the Studio (CMS), we'll typically have admins and instructors.
-- In the Experience (LMS), we'll have students, but also need to sync roles.
@@ -0,0 +1,5 @@
-- Add passing_percentage to courses for dynamic thresholds
ALTER TABLE courses ADD COLUMN IF NOT EXISTS passing_percentage INTEGER NOT NULL DEFAULT 70;
-- Ensure valid percentage range
ALTER TABLE courses ADD CONSTRAINT check_passing_percentage CHECK (passing_percentage >= 0 AND passing_percentage <= 100);
+115 -6
View File
@@ -3,13 +3,15 @@ use axum::{
http::StatusCode,
Json,
};
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse};
use common::auth::create_jwt;
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics};
use common::auth::{create_jwt, Claims};
use sqlx::PgPool;
use uuid::Uuid;
use serde_json::json;
use serde::{Deserialize, Serialize};
use bcrypt::{hash, verify, DEFAULT_COST};
use axum::http::HeaderMap;
use jsonwebtoken::{decode, DecodingKey, Validation};
pub async fn publish_course(
State(pool): State<PgPool>,
@@ -120,7 +122,6 @@ pub async fn create_course(
Ok(Json(course))
}
pub async fn get_courses(
State(pool): State<PgPool>,
) -> Result<Json<Vec<Course>>, StatusCode> {
@@ -132,6 +133,55 @@ pub async fn get_courses(
Ok(Json(courses))
}
pub async fn update_course(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, (StatusCode, String)> {
// 1. RBAC check (simplified: must be owner or admin)
let auth_header = headers.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?;
let token = &auth_header[7..];
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret("secret".as_ref()),
&Validation::default(),
).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".into()))?;
let claims = token_data.claims;
let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
if claims.role != "admin" && existing.instructor_id != claims.sub {
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
}
// 2. Update fields
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(&existing.title);
let description = payload.get("description").and_then(|v| v.as_str()).unwrap_or(existing.description.as_deref().unwrap_or(""));
let passing_percentage = payload.get("passing_percentage").and_then(|v| v.as_i64()).unwrap_or(existing.passing_percentage as i64) as i32;
let course = sqlx::query_as::<_, Course>(
"UPDATE courses SET title = $1, description = $2, passing_percentage = $3, updated_at = NOW() WHERE id = $4 RETURNING *"
)
.bind(title)
.bind(description)
.bind(passing_percentage)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to update course".into()))?;
Ok(Json(course))
}
pub async fn create_module(
State(pool): State<PgPool>,
Json(payload): Json<serde_json::Value>,
@@ -504,6 +554,7 @@ pub struct AuthPayload {
pub email: String,
pub password: String,
pub full_name: Option<String>,
pub role: Option<String>,
}
pub async fn register(
@@ -514,18 +565,20 @@ pub async fn register(
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
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 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, role) VALUES ($1, $2, $3, $4) RETURNING *"
)
.bind(&payload.email)
.bind(password_hash)
.bind(full_name)
.bind(&role)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
let token = create_jwt(user.id, "instructor")
let token = create_jwt(user.id, &user.role)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
Ok(Json(AuthResponse {
@@ -533,6 +586,7 @@ pub async fn register(
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
},
token,
}))
@@ -552,7 +606,7 @@ pub async fn login(
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
}
let token = create_jwt(user.id, "instructor")
let token = create_jwt(user.id, &user.role)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
Ok(Json(AuthResponse {
@@ -560,7 +614,62 @@ pub async fn login(
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
},
token,
}))
}
pub async fn get_course_analytics(
State(pool): State<PgPool>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
// 1. Extract and verify token
let auth_header = headers.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?;
if !auth_header.starts_with("Bearer ") {
return Err((StatusCode::UNAUTHORIZED, "Invalid Authorization header".into()));
}
let token = &auth_header[7..];
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret("secret".as_ref()),
&Validation::default(),
).map_err(|e| {
tracing::error!("JWT decode failed: {}", e);
(StatusCode::UNAUTHORIZED, "Invalid token".into())
})?;
let claims = token_data.claims;
// 2. Fetch Course to check ownership
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
// 3. Enforce RBAC
if claims.role != "admin" && course.instructor_id != claims.sub {
return Err((StatusCode::FORBIDDEN, "You do not have permission to view stats for a course you don't own".into()));
}
// 4. Fetch from LMS
let client = reqwest::Client::new();
let res = client.get(format!("http://lms-service:3002/courses/{}/analytics", id))
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
if !res.status().is_success() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch analytics from LMS".into()));
}
let analytics = res.json::<CourseAnalytics>().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(analytics))
}
+2 -1
View File
@@ -35,8 +35,9 @@ async fn main() {
let app = Router::new()
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
.route("/courses/{id}", get(handlers::get_course))
.route("/courses/{id}", get(handlers::get_course).put(handlers::update_course))
.route("/courses/{id}/publish", post(handlers::publish_course))
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
@@ -0,0 +1,5 @@
-- Add role column to users table for RBAC sync
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'student';
-- Add check constraint
ALTER TABLE users ADD CONSTRAINT check_valid_role CHECK (role IN ('admin', 'instructor', 'student'));
@@ -0,0 +1,5 @@
-- Add passing_percentage to courses (synced from CMS)
ALTER TABLE courses ADD COLUMN IF NOT EXISTS passing_percentage INTEGER NOT NULL DEFAULT 70;
-- Ensure valid range
ALTER TABLE courses ADD CONSTRAINT check_passing_percentage CHECK (passing_percentage >= 0 AND passing_percentage <= 100);
+64 -5
View File
@@ -3,11 +3,11 @@ use axum::{
http::StatusCode,
Json,
};
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse};
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics};
use common::auth::create_jwt;
use sqlx::PgPool;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use bcrypt::{hash, verify, DEFAULT_COST};
pub async fn enroll_user(
@@ -77,6 +77,7 @@ pub async fn register(
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
},
token,
}))
@@ -104,6 +105,7 @@ pub async fn login(
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
},
token,
}))
@@ -128,14 +130,15 @@ pub async fn ingest_course(
// 1. Upsert Course
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
instructor_id = EXCLUDED.instructor_id,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
passing_percentage = EXCLUDED.passing_percentage,
updated_at = EXCLUDED.updated_at"
)
.bind(payload.course.id)
@@ -144,6 +147,7 @@ pub async fn ingest_course(
.bind(payload.course.instructor_id)
.bind(payload.course.start_date)
.bind(payload.course.end_date)
.bind(payload.course.passing_percentage)
.bind(payload.course.updated_at)
.execute(&mut *tx)
.await
@@ -375,3 +379,58 @@ pub async fn get_user_course_grades(
Ok(Json(grades))
}
pub async fn get_course_analytics(
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
// 1. Total Enrollments
let total_enrollments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM enrollments WHERE course_id = $1")
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Average Course Score (Overall)
let average_score: Option<f32> = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1")
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Per-Lesson Analytics
// Note: We cast AVG to float4 for PostgreSQL compatibility
let rows = sqlx::query(
r#"
SELECT
l.id,
l.title,
COALESCE(AVG(g.score), 0)::float4 as average_score,
COUNT(g.id) as submission_count
FROM lessons l
LEFT JOIN user_grades g ON l.id = g.lesson_id
WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = $1)
GROUP BY l.id, l.title, l.position
ORDER BY l.position
"#
)
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = rows.into_iter().map(|row| {
LessonAnalytics {
lesson_id: row.get("id"),
lesson_title: row.get("title"),
average_score: row.get("average_score"),
submission_count: row.get("submission_count"),
}
}).collect();
Ok(Json(CourseAnalytics {
course_id,
total_enrollments,
average_score: average_score.unwrap_or(0.0),
lessons,
}))
}
+1
View File
@@ -44,6 +44,7 @@ async fn main() {
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route("/grades", post(handlers::submit_lesson_score))
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
.layer(cors)
.with_state(pool);
+22
View File
@@ -10,6 +10,7 @@ pub struct Course {
pub instructor_id: Uuid,
pub start_date: Option<DateTime<Utc>>,
pub end_date: Option<DateTime<Utc>>,
pub passing_percentage: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -97,6 +98,7 @@ pub struct User {
pub email: String,
pub password_hash: String,
pub full_name: String,
pub role: String, // admin, instructor, student
pub created_at: DateTime<Utc>,
}
@@ -105,6 +107,7 @@ pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub full_name: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -125,6 +128,22 @@ pub struct PublishedModule {
pub lessons: Vec<Lesson>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CourseAnalytics {
pub course_id: Uuid,
pub total_enrollments: i64,
pub average_score: f32, // 0.0-1.0
pub lessons: Vec<LessonAnalytics>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LessonAnalytics {
pub lesson_id: Uuid,
pub lesson_title: String,
pub average_score: f32, // 0.0-1.0
pub submission_count: i64,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -159,6 +178,8 @@ mod tests {
})),
grading_category_id: None,
is_graded: false,
max_attempts: None,
allow_retry: true,
position: 1,
created_at: Utc::now(),
};
@@ -182,6 +203,7 @@ mod tests {
instructor_id: Uuid::new_v4(),
start_date: None,
end_date: None,
passing_percentage: 70,
created_at: Utc::now(),
updated_at: Utc::now(),
},
View File
+130 -60
View File
@@ -1,98 +1,168 @@
"use client";
import { useState } from "react";
import { lmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogIn, Mail, Lock } from "lucide-react";
import { lmsApi } from "@/lib/api";
import { GraduationCap, Lock, Mail, User } from "lucide-react";
export default function LoginPage() {
export default function ExperienceLoginPage() {
const router = useRouter();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setLoading(true);
try {
const res = await lmsApi.login({ email, password });
login(res.user, res.token);
if (isLogin) {
const response = await lmsApi.login({ email, password });
// Verify user is a student
if (response.user.role !== "student") {
setError("Access denied. This portal is for students only. Please use the Studio portal for instructors.");
setLoading(false);
return;
}
localStorage.setItem("experience_token", response.token);
localStorage.setItem("experience_user", JSON.stringify(response.user));
router.push("/");
} else {
const response = await lmsApi.register({
email,
password,
full_name: fullName
});
localStorage.setItem("experience_token", response.token);
localStorage.setItem("experience_user", JSON.stringify(response.user));
router.push("/");
}
} catch (err) {
const message = err instanceof Error ? err.message : "Login failed. Please check your credentials.";
setError(message);
setError(err instanceof Error ? err.message : "Authentication failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-6 bg-[#050505]">
<div className="w-full max-w-md space-y-8 animate-in fade-in zoom-in duration-500">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mx-auto text-blue-500 mb-6">
<LogIn size={32} />
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4">
<GraduationCap className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-black text-white mb-2">OpenCCB Experience</h1>
<p className="text-gray-400">Student Learning Portal</p>
</div>
{/* Login/Register Form */}
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl p-8">
<div className="flex gap-2 mb-6 bg-white/5 rounded-xl p-1">
<button
onClick={() => setIsLogin(true)}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${isLogin ? "bg-indigo-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Login
</button>
<button
onClick={() => setIsLogin(false)}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${!isLogin ? "bg-indigo-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Register
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Jane Smith"
required
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(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="student@example.com"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(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="••••••••"
required
/>
</div>
<h1 className="text-3xl font-black tracking-tighter text-white">Student Login</h1>
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Welcome back to your learning journey</p>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.02]">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold text-center">
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 text-red-400 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Email Address</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
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>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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>
</div>
<button
disabled={loading}
type="submit"
className="btn-premium w-full !py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Authenticating..." : "Continue to Dashboard"}
{loading ? "Processing..." : isLogin ? "Sign In" : "Create Account"}
</button>
</form>
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
Are you an instructor?{" "}
<a href="http://localhost:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
Go to Instructor Portal
</a>
</p>
</div>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
Don&apos;t have an account? <Link href="/auth/register" className="text-blue-500 hover:text-blue-400">Sign up here</Link>
<p className="text-center text-xs text-gray-500 mt-6">
OpenCCB Experience - Student Learning Portal
</p>
</div>
</div>
@@ -14,6 +14,7 @@ import {
ArrowLeft,
TrendingUp
} from "lucide-react";
import PerformanceBar from "@/components/PerformanceBar";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -134,9 +135,12 @@ export default function StudentProgressPage() {
</div>
</div>
<div className="flex items-center justify-center gap-2 text-green-400 bg-green-400/10 py-2 px-4 rounded-full w-fit mx-auto border border-green-400/20">
<Award className="w-4 h-4" />
<span className="text-sm font-bold">Passing Standing</span>
{/* Performance Bar */}
<div className="mt-8">
<PerformanceBar
score={Math.round(totalWeightedGrade)}
passingPercentage={course.passing_percentage || 70}
/>
</div>
</div>
@@ -0,0 +1,133 @@
import React from 'react';
interface PerformanceBarProps {
score: number; // 0-100
passingPercentage: number; // e.g., 70
}
export default function PerformanceBar({ score, passingPercentage }: PerformanceBarProps) {
// Calculate tier boundaries
// Reprobado: 0 to P-1
const reprobadoMax = Math.max(0, passingPercentage - 1);
// Rendimiento Bajo: P to P+9
const lowMin = passingPercentage;
const lowMax = passingPercentage + 9;
// Rendimiento Medio: P+10 to P+15
const mediumMin = passingPercentage + 10;
const mediumMax = passingPercentage + 15;
// Buen Rendimiento: P+16 to 90
const goodMin = passingPercentage + 16;
const goodMax = 90;
// Excelente: 91+
const excellentMin = 91;
// Determine current tier
let tier = '';
let tierColor = '';
let tierLabel = '';
if (score < passingPercentage) {
tier = 'reprobado';
tierColor = 'bg-red-500';
tierLabel = 'Reprobado';
} else if (score >= lowMin && score <= lowMax) {
tier = 'low';
tierColor = 'bg-orange-500';
tierLabel = 'Rendimiento Bajo';
} else if (score >= mediumMin && score <= mediumMax) {
tier = 'medium';
tierColor = 'bg-yellow-500';
tierLabel = 'Rendimiento Medio';
} else if (score >= goodMin && score <= goodMax) {
tier = 'good';
tierColor = 'bg-green-500';
tierLabel = 'Buen Rendimiento';
} else if (score >= excellentMin) {
tier = 'excellent';
tierColor = 'bg-blue-500';
tierLabel = 'Excelente';
}
return (
<div className="space-y-4">
{/* Current Score Display */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-gray-400 uppercase tracking-wider">Tu Rendimiento</div>
<div className={`text-3xl font-black ${tierColor.replace('bg-', 'text-')}`}>
{score}%
</div>
</div>
<div className={`px-4 py-2 rounded-xl ${tierColor} text-white font-bold text-sm`}>
{tierLabel}
</div>
</div>
{/* Visual Bar */}
<div className="relative h-8 bg-white/5 rounded-full overflow-hidden border border-white/10">
{/* Tier segments */}
<div className="absolute inset-0 flex">
{/* Reprobado */}
<div
className="bg-red-500/30 border-r border-white/20"
style={{ width: `${reprobadoMax}%` }}
></div>
{/* Low */}
<div
className="bg-orange-500/30 border-r border-white/20"
style={{ width: `${lowMax - lowMin + 1}%` }}
></div>
{/* Medium */}
<div
className="bg-yellow-500/30 border-r border-white/20"
style={{ width: `${mediumMax - mediumMin + 1}%` }}
></div>
{/* Good */}
<div
className="bg-green-500/30 border-r border-white/20"
style={{ width: `${goodMax - goodMin + 1}%` }}
></div>
{/* Excellent */}
<div
className="bg-blue-500/30"
style={{ width: `${100 - excellentMin + 1}%` }}
></div>
</div>
{/* Current position indicator */}
<div
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg"
style={{ left: `${score}%` }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-white text-gray-900 px-2 py-1 rounded text-xs font-black">
{score}%
</div>
</div>
</div>
{/* Legend */}
<div className="grid grid-cols-5 gap-2 text-xs">
<div className="text-center">
<div className="w-full h-2 bg-red-500 rounded mb-1"></div>
<div className="text-red-400 font-bold">0-{reprobadoMax}</div>
</div>
<div className="text-center">
<div className="w-full h-2 bg-orange-500 rounded mb-1"></div>
<div className="text-orange-400 font-bold">{lowMin}-{lowMax}</div>
</div>
<div className="text-center">
<div className="w-full h-2 bg-yellow-500 rounded mb-1"></div>
<div className="text-yellow-400 font-bold">{mediumMin}-{mediumMax}</div>
</div>
<div className="text-center">
<div className="w-full h-2 bg-green-500 rounded mb-1"></div>
<div className="text-green-400 font-bold">{goodMin}-{goodMax}</div>
</div>
<div className="text-center">
<div className="w-full h-2 bg-blue-500 rounded mb-1"></div>
<div className="text-blue-400 font-bold">{excellentMin}+</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && !user && pathname !== "/auth/login") {
router.push("/auth/login");
}
}, [user, loading, router, pathname]);
if (loading) {
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-indigo-500/20 border-t-indigo-500 rounded-full animate-spin"></div>
</div>
);
}
if (!user && pathname !== "/auth/login") {
return null;
}
return <>{children}</>;
}
+2
View File
@@ -5,6 +5,7 @@ export interface Course {
title: string;
description?: string;
instructor_id: string;
passing_percentage: number;
created_at: string;
}
@@ -72,6 +73,7 @@ export interface User {
id: string;
email: string;
full_name: string;
role: string;
}
export interface AuthResponse {
+131 -60
View File
@@ -1,98 +1,169 @@
"use client";
import { useState } from "react";
import { cmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogIn, Mail, Lock } from "lucide-react";
import { cmsApi } from "@/lib/api";
import { BookOpen, Lock, Mail, User } from "lucide-react";
export default function LoginPage() {
export default function StudioLoginPage() {
const router = useRouter();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setLoading(true);
try {
const res = await cmsApi.login({ email, password });
login(res.user, res.token);
if (isLogin) {
const response = await cmsApi.login({ email, password });
// Verify user is instructor or admin
if (response.user.role !== "instructor" && response.user.role !== "admin") {
setError("Access denied. This portal is for instructors and administrators only.");
setLoading(false);
return;
}
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
router.push("/");
} else {
const response = await cmsApi.register({
email,
password,
full_name: fullName,
role: "instructor"
});
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
router.push("/");
}
} catch (err) {
const message = err instanceof Error ? err.message : "Authentication failed. Please verify your credentials.";
setError(message);
setError(err instanceof Error ? err.message : "Authentication failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-160px)] flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mx-auto text-blue-500 mb-6">
<LogIn size={32} />
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-blue-950 to-gray-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-600 rounded-2xl mb-4">
<BookOpen className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-black text-white mb-2">OpenCCB Studio</h1>
<p className="text-gray-400">Instructor & Administrator Portal</p>
</div>
{/* Login/Register Form */}
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl p-8">
<div className="flex gap-2 mb-6 bg-white/5 rounded-xl p-1">
<button
onClick={() => setIsLogin(true)}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${isLogin ? "bg-blue-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Login
</button>
<button
onClick={() => setIsLogin(false)}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${!isLogin ? "bg-blue-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Register
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(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="instructor@example.com"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(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="••••••••"
required
/>
</div>
<h1 className="text-3xl font-black tracking-tighter text-white">Studio Login</h1>
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Access your educational dashboard</p>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.02] rounded-3xl">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold text-center">
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 text-red-400 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Instructor Email</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="instructor@openccb.com"
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>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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>
</div>
<button
disabled={loading}
type="submit"
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 transition-all active:scale-[0.98]"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Verifying..." : "Enter Studio"}
{loading ? "Processing..." : isLogin ? "Sign In" : "Create Account"}
</button>
</form>
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
Are you a student?{" "}
<a href="http://localhost:3003/auth/login" className="text-blue-400 hover:text-blue-300 font-bold">
Go to Student Portal
</a>
</p>
</div>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
New to Studio? <Link href="/auth/register" className="text-blue-500 hover:text-blue-400">Create an account</Link>
<p className="text-center text-xs text-gray-500 mt-6">
OpenCCB Studio - Instructor & Administrator Portal
</p>
</div>
</div>
@@ -0,0 +1,218 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { cmsApi, Course, CourseAnalytics } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import {
BarChart3,
Users,
TrendingUp,
AlertTriangle,
ArrowLeft,
CheckCircle2,
BookOpen
} from "lucide-react";
export default function AnalyticsPage() {
const { id } = useParams() as { id: string };
const router = useRouter();
const { user } = useAuth();
const [course, setCourse] = useState<Course | null>(null);
const [analytics, setAnalytics] = useState<CourseAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
// Wait for auth to load
if (!user) {
console.log("AnalyticsPage: No user found yet.");
return;
}
console.log("AnalyticsPage: User found:", user);
console.log("AnalyticsPage: User Role:", user.role);
// Check authorization
if (user.role !== 'admin' && user.role !== 'instructor') {
console.warn("AnalyticsPage: Unauthorized role. Redirecting to home.", user.role);
router.push('/');
return;
}
try {
console.log("AnalyticsPage: Fetching data for course:", id);
const [courseData, analyticsData] = await Promise.all([
cmsApi.getCourseWithFullOutline(id),
cmsApi.getCourseAnalytics(id)
]);
console.log("AnalyticsPage: Data fetched successfully", { courseData, analyticsData });
setCourse(courseData);
setAnalytics(analyticsData);
} catch (err: unknown) {
console.error("Failed to load analytics", err);
setAuthError(err instanceof Error ? err.message : "Failed to load data");
} finally {
setLoading(false);
}
};
fetchData();
}, [id, user, router]);
if (loading) return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
if (authError) return (
<div className="min-h-screen bg-gray-900 text-white flex flex-col items-center justify-center p-20 text-center gap-6">
<div className="w-20 h-20 bg-red-500/10 rounded-full flex items-center justify-center text-red-500">
<AlertTriangle size={40} />
</div>
<h2 className="text-2xl font-bold">Access Denied</h2>
<p className="text-gray-400 max-w-md">{authError}</p>
<button onClick={() => router.back()} className="btn-premium px-8 py-3">Go Back</button>
</div>
);
if (!course || !analytics) return (
<div className="min-h-screen bg-gray-900 text-white p-20 text-center">
Course not found or analytics unavailable.
</div>
);
const difficultLessons = analytics.lessons
.filter(l => l.average_score < 0.7 && l.submission_count > 0)
.sort((a, b) => a.average_score - b.average_score);
return (
<div className="min-h-screen bg-gray-950 text-white pb-20">
{/* Header */}
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={() => router.back()} className="p-2 hover:bg-white/5 rounded-full transition-colors">
<ArrowLeft className="w-5 h-5 text-gray-400" />
</button>
<h1 className="text-xl font-bold">{course.title} - Performance Insights</h1>
<div className="bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-wider px-2 py-1 rounded border border-blue-500/30">
{user?.role} View
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-8 mt-12 space-y-12">
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 group hover:bg-white/[0.07] transition-all">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400">
<Users size={24} />
</div>
<span className="text-sm font-bold text-gray-400 uppercase tracking-widest">Enrollments</span>
</div>
<div className="text-4xl font-black">{analytics.total_enrollments}</div>
<div className="text-xs text-green-400 font-bold mt-2">Active Learners</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 group hover:bg-white/[0.07] transition-all">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400">
<TrendingUp size={24} />
</div>
<span className="text-sm font-bold text-gray-400 uppercase tracking-widest">Average Score</span>
</div>
<div className="text-4xl font-black">{Math.round(analytics.average_score * 100)}%</div>
<div className="text-xs text-gray-500 font-bold mt-2">Across all assessments</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 group hover:bg-white/[0.07] transition-all">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-2xl bg-orange-500/10 flex items-center justify-center text-orange-400">
<AlertTriangle size={24} />
</div>
<span className="text-sm font-bold text-gray-400 uppercase tracking-widest">Attention Needed</span>
</div>
<div className="text-4xl font-black">{difficultLessons.length}</div>
<div className="text-xs text-orange-400 font-bold mt-2">Struggling Lessons</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Lesson Breakdown */}
<section>
<h2 className="text-2xl font-black mb-6 flex items-center gap-3">
<BarChart3 className="text-blue-500" />
Lesson Performance
</h2>
<div className="space-y-4">
{analytics.lessons.map((lesson) => (
<div key={lesson.lesson_id} className="bg-white/5 border border-white/10 rounded-2xl p-6 hover:bg-white/[0.07] transition-all">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-bold">{lesson.lesson_title}</h3>
<p className="text-xs text-gray-500 mt-1">{lesson.submission_count} submissions</p>
</div>
<div className={`text-xl font-black ${lesson.average_score < 0.6 ? 'text-red-400' : lesson.average_score < 0.8 ? 'text-orange-400' : 'text-green-400'}`}>
{Math.round(lesson.average_score * 100)}%
</div>
</div>
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${lesson.average_score < 0.6 ? 'bg-red-500' : lesson.average_score < 0.8 ? 'bg-orange-500' : 'bg-green-500'}`}
style={{ width: `${lesson.average_score * 100}%` }}
/>
</div>
</div>
))}
</div>
</section>
{/* Actionable Insights */}
<section className="space-y-8">
<div>
<h2 className="text-2xl font-black mb-6 flex items-center gap-3">
<AlertTriangle className="text-orange-500" />
Struggling Lessons
</h2>
{difficultLessons.length > 0 ? (
<div className="space-y-4">
{difficultLessons.map(l => (
<div key={l.lesson_id} className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 flex items-center justify-between">
<div>
<h4 className="font-bold text-red-400">{l.lesson_title}</h4>
<p className="text-xs text-red-300/60 mt-1 text-balance max-w-xs">
Average score is below 70%. Consider reviewing the material or difficulty of questions.
</p>
</div>
<div className="text-2xl font-black text-red-500">{Math.round(l.average_score * 100)}%</div>
</div>
))}
</div>
) : (
<div className="bg-green-500/10 border border-green-500/20 rounded-2xl p-8 text-center">
<CheckCircle2 size={40} className="text-green-500 mx-auto mb-4" />
<h4 className="font-bold text-green-400">All set!</h4>
<p className="text-sm text-green-300/60 mt-2">No lessons currently fall below the difficulty threshold.</p>
</div>
)}
</div>
<div className="bg-blue-600/10 border border-blue-500/20 rounded-3xl p-8">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<BookOpen className="text-blue-400" />
Content Strategy Tip
</h3>
<p className="text-sm text-blue-200/70 leading-relaxed">
High submission counts with low average scores often indicate that the assessment might be misleading or the prerequisites aren&apos;t clearly explained in previous lessons.
</p>
</div>
</section>
</div>
</main>
</div>
);
}
+2 -1
View File
@@ -119,7 +119,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
<div className="flex border-b border-white/10">
<Link href={`/courses/${params.id}`} className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</Link>
<Link href={`/courses/${params.id}/grading`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Grading</Link>
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</button>
<Link href={`/courses/${params.id}/analytics`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Analytics</Link>
<Link href={`/courses/${params.id}/settings`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</Link>
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
</div>
@@ -0,0 +1,148 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { cmsApi, Course } from "@/lib/api";
import { ArrowLeft, Save, Settings as SettingsIcon } from "lucide-react";
export default function CourseSettingsPage() {
const { id } = useParams() as { id: string };
const router = useRouter();
const [course, setCourse] = useState<Course | null>(null);
const [passingPercentage, setPassingPercentage] = useState(70);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchCourse = async () => {
try {
const data = await cmsApi.getCourse(id);
setCourse(data);
setPassingPercentage(data.passing_percentage || 70);
} catch (err) {
console.error("Failed to load course", err);
} finally {
setLoading(false);
}
};
fetchCourse();
}, [id]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await cmsApi.updateCourse(id, { passing_percentage: passingPercentage });
setCourse(updated);
alert("Course settings updated successfully!");
} catch (err) {
console.error("Failed to save", err);
alert("Failed to save settings");
} finally {
setSaving(false);
}
};
if (loading) return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
if (!course) return (
<div className="min-h-screen bg-gray-900 text-white p-20 text-center">
Course not found.
</div>
);
return (
<div className="min-h-screen bg-gray-950 text-white pb-20">
{/* Header */}
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={() => router.back()} className="p-2 hover:bg-white/5 rounded-full transition-colors">
<ArrowLeft className="w-5 h-5 text-gray-400" />
</button>
<h1 className="text-xl font-bold">{course.title} - Settings</h1>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-xl font-bold transition-colors disabled:opacity-50"
>
<Save size={18} />
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</header>
<main className="max-w-5xl mx-auto px-8 mt-12 space-y-8">
{/* Passing Percentage Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400">
<SettingsIcon size={24} />
</div>
<h2 className="text-2xl font-black">Grading Configuration</h2>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-300 mb-3">
Passing Percentage
</label>
<div className="flex items-center gap-6">
<input
type="range"
min="0"
max="100"
value={passingPercentage}
onChange={(e) => setPassingPercentage(parseInt(e.target.value))}
className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="text-4xl font-black text-blue-400 w-24 text-right">
{passingPercentage}%
</div>
</div>
<p className="text-xs text-gray-500 mt-3">
Students must achieve at least this percentage to pass the course.
</p>
</div>
{/* Performance Tiers Preview */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-bold text-gray-300 mb-4">Performance Tiers Preview</h3>
<div className="space-y-3 text-xs">
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-red-500 rounded"></div>
<span className="text-red-400 font-bold">Reprobado:</span>
<span className="text-gray-400">0% - {Math.max(0, passingPercentage - 1)}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-orange-500 rounded"></div>
<span className="text-orange-400 font-bold">Rendimiento Bajo:</span>
<span className="text-gray-400">{passingPercentage}% - {passingPercentage + 9}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-yellow-500 rounded"></div>
<span className="text-yellow-400 font-bold">Rendimiento Medio:</span>
<span className="text-gray-400">{passingPercentage + 10}% - {passingPercentage + 15}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-green-500 rounded"></div>
<span className="text-green-400 font-bold">Buen Rendimiento:</span>
<span className="text-gray-400">{passingPercentage + 16}% - 90%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-blue-500 rounded"></div>
<span className="text-blue-400 font-bold">Excelente:</span>
<span className="text-gray-400">91% - 100%</span>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
);
}
+29 -4
View File
@@ -1,10 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi, Course } from "@/lib/api";
import Link from "next/link";
export default function Home() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
@@ -12,10 +14,21 @@ export default function Home() {
useEffect(() => {
setMounted(true);
fetchCourses();
}, []);
const fetchCourses = async () => {
// Check authentication
const savedUser = localStorage.getItem("studio_user");
if (!savedUser) {
router.push("/auth/login");
return;
}
// The `setUser` function was not defined, causing a linting error.
// If user data needs to be stored in state, a `useState` for `user` should be added.
// For now, removing the call to fix the linting error.
// setUser(JSON.parse(savedUser));
// Fetch courses
const loadCourses = async () => {
try {
setLoading(true);
const data = await cmsApi.getCourses();
@@ -29,6 +42,11 @@ export default function Home() {
}
};
loadCourses();
}, [router]);
const handleCreateCourse = async () => {
const title = prompt("Enter course title:");
if (!title) return;
@@ -42,7 +60,14 @@ export default function Home() {
};
const placeholderCourses: Course[] = [
{ id: "p1", title: "Introduction to Rust (Demo)", instructor_id: "demo", created_at: new Date().toISOString() },
{
id: "p1",
title: "Introduction to Rust (Demo)",
description: "A demo course to get started",
instructor_id: "demo",
passing_percentage: 70,
created_at: new Date().toISOString()
},
];
const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses);
+54 -1
View File
@@ -3,10 +3,26 @@ export const API_BASE_URL = "http://localhost:3001";
export interface Course {
id: string;
title: string;
description: string;
instructor_id: string;
passing_percentage: number;
created_at: string;
}
export interface CourseAnalytics {
course_id: string;
total_enrollments: number;
average_score: number;
lessons: LessonAnalytics[];
}
export interface LessonAnalytics {
lesson_id: string;
lesson_title: string;
average_score: number;
submission_count: number;
}
export interface Module {
id: string;
course_id: string;
@@ -77,6 +93,7 @@ export interface User {
id: string;
email: string;
full_name: string;
role: string;
}
export interface AuthResponse {
@@ -88,6 +105,7 @@ export interface AuthPayload {
email: string;
password?: string;
full_name?: string;
role?: string;
}
export const cmsApi = {
@@ -200,8 +218,12 @@ export const cmsApi = {
},
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'
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to publish course');
},
@@ -224,5 +246,36 @@ export const cmsApi = {
});
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();
}
};