diff --git a/.env.example b/.env.example
index 2031189..dc50e84 100644
--- a/.env.example
+++ b/.env.example
@@ -11,3 +11,13 @@ JWT_SECRET=supersecret
# Logging
RUST_LOG=info
+
+# AI Configuration
+# Providers: 'openai' or 'local'
+AI_PROVIDER=local
+OPENAI_API_KEY=
+
+# Local AI (Ollama & Whisper)
+LOCAL_WHISPER_URL=http://localhost:8000
+LOCAL_OLLAMA_URL=http://localhost:11434
+LOCAL_LLM_MODEL=llama3
diff --git a/Cargo.lock b/Cargo.lock
index cb59b74..a1b87d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1535,6 +1535,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
+ "futures-util",
"h2",
"http",
"http-body",
@@ -1546,6 +1547,7 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
diff --git a/Cargo.toml b/Cargo.toml
index 0bc2f03..bdfb744 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,4 +25,4 @@ jsonwebtoken = "9.3"
bcrypt = "0.17"
dotenvy = "0.15"
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
-reqwest = { version = "0.12", features = ["json"] }
+reqwest = { version = "0.12", features = ["json", "multipart"] }
diff --git a/README.md b/README.md
index 4c2b103..dde5923 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ OpenCCB es una infraestructura de código abierto para plataformas de gestión d
## 🚀 Estado del Proyecto
-El sistema se encuentra en una fase madura (**Phase 6 en progreso**), con una API robusta para la gestión de cursos, autenticación segura, multi-tenencia y análisis de datos.
+El sistema se encuentra en una fase avanzada (**Phase 6 en progreso**), ofreciendo una infraestructura multi-inquilino de alto rendimiento, gestión de marcas por organización (branding), autenticación segura y análisis detallado de datos.
Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades.
@@ -136,6 +136,32 @@ El servicio CMS expone una API RESTful en el puerto `3001`. A continuación se d
- **URL**: `GET /audit-logs`
- **Query Params**: `?page=1&limit=50`
+### 🏢 Organizaciones & Branding
+
+#### Listar Organizaciones (Admin)
+- **URL**: `GET /organizations`
+- **Descripción**: Obtiene la lista completa de inquilinos del sistema.
+
+#### Configurar Branding
+- **URL**: `PUT /organizations/{id}/branding`
+- **Descripción**: Actualiza los colores primario y secundario de la organización.
+- **Body (JSON)**:
+ ```json
+ {
+ "primary_color": "#hex",
+ "secondary_color": "#hex"
+ }
+ ```
+
+#### Subir Logo de Organización
+- **URL**: `POST /organizations/{id}/logo`
+- **Tipo**: `multipart/form-data`
+- **Campo**: `file` (Binary)
+
+#### Obtener Branding Público
+- **URL**: `GET /organizations/{id}/branding`
+- **Descripción**: Recupera la identidad visual (logo y colores) de una organización.
+
## 📦 Configuración y Ejecución
1. **Variables de Entorno**:
diff --git a/docker-compose.yml b/docker-compose.yml
index ccf5c06..ec526d2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -52,6 +52,24 @@ services:
environment:
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
+ whisper:
+ image: fedirz/faster-whisper-server:latest-cuda
+ ports:
+ - "8000:8000"
+ volumes:
+ - whisper_cache:/root/.cache/huggingface
+ environment:
+ - WHISPER_MODEL=medium
+ - DEVICE=cuda
+ # GPU support for RTX 2070 Super
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [ gpu ]
+
e2e:
build:
context: ./e2e
@@ -69,3 +87,4 @@ services:
volumes:
postgres_data:
uploads_data:
+ whisper_cache:
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..2f5ba13
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,244 @@
+#!/bin/bash
+
+# OpenCCB Unified Installation Script
+# This script automates the setup of OpenCCB, including prerequisites,
+# repository cloning, dependencies, and initial configuration.
+
+set -e
+
+REPO_URL="https://github.com/Nurfog/openccb.git" # Example URL, should be updated if needed
+PROJECT_DIR="openccb"
+
+echo "===================================================="
+echo " 🚀 Welcome to the OpenCCB Installer"
+echo "===================================================="
+echo ""
+
+# 1. Detection & Cloning
+if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
+ echo "✅ Project detected in current directory."
+ PROJECT_ROOT=$(pwd)
+else
+ echo "📂 Project not detected in current directory."
+ if [ -d "$PROJECT_DIR" ]; then
+ echo "✅ Detected project folder '$PROJECT_DIR'."
+ cd "$PROJECT_DIR"
+ PROJECT_ROOT=$(pwd)
+ else
+ echo "📥 Project folder not found. Cloning from $REPO_URL..."
+ git clone "$REPO_URL" "$PROJECT_DIR"
+ cd "$PROJECT_DIR"
+ PROJECT_ROOT=$(pwd)
+ fi
+fi
+
+# 2. Prerequisite Installation
+echo ""
+echo "🔍 Checking for prerequisites..."
+
+# Function to check and install system packages (Ubuntu/Debian)
+install_pkg() {
+ if ! command -v "$1" &> /dev/null; then
+ echo "🔧 Installing $1..."
+ sudo apt-get update && sudo apt-get install -y "$1"
+ else
+ echo "✅ $1 is already installed."
+ fi
+}
+
+# Check for essential tools
+if [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ if [ -f /etc/debian_version ]; then
+ install_pkg "curl"
+ install_pkg "git"
+ install_pkg "jq"
+ install_pkg "build-essential"
+ install_pkg "docker.io"
+ # On modern Ubuntu, docker compose is a plugin included with docker.io or available as docker-compose-v2
+ if ! docker compose version &> /dev/null; then
+ install_pkg "docker-compose-v2"
+ fi
+ else
+ echo "⚠️ Unsupported Linux distribution. Please ensure curl, git, jq, docker, and docker-compose are installed."
+ fi
+fi
+
+# Check for Rust
+if ! command -v cargo &> /dev/null; then
+ echo "🔧 Installing Rust..."
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+ source $HOME/.cargo/env
+else
+ echo "✅ Rust (Cargo) is already installed."
+fi
+
+# Check for Node.js
+if ! command -v node &> /dev/null; then
+ echo "🔧 Node.js not found. Installing via NVM..."
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
+ export NVM_DIR="$HOME/.nvm"
+ [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
+ nvm install --lts
+else
+ echo "✅ Node.js $(node -v) is already installed."
+fi
+
+# Check for sqlx-cli
+if ! command -v sqlx &> /dev/null; then
+ echo "🔧 Installing sqlx-cli..."
+ cargo install sqlx-cli --no-default-features --features postgres
+else
+ echo "✅ sqlx-cli is already installed."
+fi
+
+# AI Stack Detection & Installation
+echo ""
+echo "🤖 Setting up Local AI Stack..."
+
+HAS_NVIDIA=false
+if command -v nvidia-smi &> /dev/null; then
+ if nvidia-smi -L &> /dev/null; then
+ echo "🚀 NVIDIA GPU Detected!"
+ HAS_NVIDIA=true
+ fi
+fi
+
+if [ "$HAS_NVIDIA" = false ] && command -v lspci &> /dev/null; then
+ if lspci | grep -i nvidia &> /dev/null; then
+ echo "🚀 NVIDIA GPU Detected (lspci)!"
+ HAS_NVIDIA=true
+ fi
+fi
+
+# Ollama Installation
+if ! command -v ollama &> /dev/null; then
+ echo "🔧 Installing Ollama..."
+ curl -fsSL https://ollama.com/install.sh | sh
+else
+ echo "✅ Ollama is already installed."
+fi
+
+# Wait for Ollama to be ready
+echo "⏳ Waiting for Ollama server to be ready..."
+until curl -s http://localhost:11434/api/tags &> /dev/null; do
+ sleep 2
+done
+
+# Pre-download models based on hardware
+if [ "$HAS_NVIDIA" = true ]; then
+ echo "📥 Downloading Llama 3 (optimized for GPU)..."
+ ollama pull llama3:8b
+else
+ echo "📥 Downloading Phi-3 (lighter for CPU)..."
+ ollama pull phi3:mini
+fi
+
+# 3. Frontend Dependency Installation
+echo ""
+echo "📦 Installing frontend dependencies..."
+for dir in "web/studio" "web/experience"; do
+ if [ -d "$dir" ]; then
+ echo "🔹 Installing in $dir..."
+ (cd "$dir" && npm install)
+ fi
+done
+
+# 4. Environment Configuration
+echo ""
+echo "⚙️ Configuring environment..."
+
+if [ ! -f ".env" ]; then
+ if [ -f ".env.example" ]; then
+ echo "📄 Creating .env from .env.example..."
+ cp .env.example .env
+ else
+ echo "📄 Creating a new .env file..."
+ touch .env
+ fi
+fi
+
+# Function to update or add a variable in .env
+update_env() {
+ local key=$1
+ local default_value=$2
+ local prompt_text=$3
+
+ # Read current value if it exists
+ local current_value=$(grep "^${key}=" .env | cut -d'=' -f2- || echo "")
+ local val=${current_value:-$default_value}
+
+ read -p "$prompt_text [$val]: " user_val
+ user_val=${user_val:-$val}
+
+ if grep -q "^${key}=" .env; then
+ # Use a temporary file for sed to be safe
+ sed -i "s|^${key}=.*|${key}=${user_val}|" .env
+ else
+ echo "${key}=${user_val}" >> .env
+ fi
+}
+
+echo "Please provide the following configuration values (Press Enter for default):"
+update_env "DATABASE_URL" "postgresql://user:password@localhost:5432/openccb" "Master Database URL"
+update_env "CMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_cms" "CMS Database URL"
+update_env "LMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_lms" "LMS Database URL"
+update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" "Studio CMS API URL"
+update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" "Experience LMS API URL"
+
+echo ""
+echo "🛠️ AI Configuration..."
+update_env "AI_PROVIDER" "local" "AI Provider (openai | local)"
+if [ "$(grep "^AI_PROVIDER=" .env | cut -d'=' -f2)" == "local" ]; then
+ update_env "LOCAL_OLLAMA_URL" "http://localhost:11434" "Local Ollama API URL"
+ update_env "LOCAL_WHISPER_URL" "http://localhost:8000" "Local Whisper API URL"
+
+ default_model="phi3:mini"
+ if [ "$HAS_NVIDIA" = true ]; then
+ default_model="llama3:8b"
+ fi
+ update_env "LOCAL_LLM_MODEL" "$default_model" "Local LLM Model"
+else
+ update_env "OPENAI_API_KEY" "" "OpenAI API Key"
+fi
+
+echo "✅ .env configuration updated."
+
+# 5. Database Initialization
+echo ""
+echo "🐘 Starting database with Docker..."
+docker compose up -d db
+
+echo "⏳ Waiting for database to be ready..."
+# Better wait using pg_isready if available
+if command -v pg_isready &> /dev/null; then
+ until pg_isready -h localhost -p 5432 -U user; do
+ echo "Still waiting for Postgres..."
+ sleep 2
+ done
+else
+ sleep 10
+fi
+
+echo "🏗️ Running database setup..."
+chmod +x db-mgmt.sh
+./db-mgmt.sh setup
+
+# 6. System Initialization
+echo ""
+echo "👤 Initializing system (Admin account)..."
+chmod +x init-system.sh
+./init-system.sh
+
+echo ""
+echo "===================================================="
+echo " ✨ OpenCCB Installation Complete!"
+echo "===================================================="
+echo "You can now start the services using 'docker compose up' or by"
+echo "running 'npm run dev' inside the frontend directories and"
+echo "'cargo run' inside the service directories."
+echo ""
+echo "Studio: http://localhost:3000"
+echo "Experience: http://localhost:3003"
+echo "CMS API: http://localhost:3001"
+echo "LMS API: http://localhost:3002"
+echo "===================================================="
diff --git a/next b/next
deleted file mode 100644
index e69de29..0000000
diff --git a/roadmap.md b/roadmap.md
index bd7b915..298dbb9 100644
--- a/roadmap.md
+++ b/roadmap.md
@@ -75,6 +75,11 @@
- [x] Update Rust models & JWT Claims
- [x] Implement Axum middleware for organization context
- [x] Update Frontend registration to support organizations
+- [x] **Organization Branding**: Custom identity per tenant (Completed)
+ - [x] Logo upload & optimization
+ - [x] Custom color schemes (Primary/Secondary)
+ - [x] Dynamic Experience Portal adaptation
+ - [x] Live Branding Preview in Studio
- [ ] **Advanced Analytics**:
- [ ] Cohort analysis
- [ ] Retention metrics
@@ -88,10 +93,15 @@
- [x] Badges and achievements (Implemented base system)
- [ ] Leaderboards
- [ ] XP and leveling system
-- [ ] **Communication**:
- - [ ] Discussion forums
- - [ ] Direct messaging
- - [ ] Announcements
+- [x] **Course Management Enhancements**:
+ - [x] Manual naming for modules, lessons, and activities during creation.
+ - [x] Drag-and-drop reordering for modules, lessons, and activities.
+ - [x] **Pacing Control**:
+ - [x] Self-paced mode (Evergreen).
+ - [x] Instructor-led mode (Cohort-based with start/end dates).
+ - [x] **Course Calendar**:
+ - [x] Management of important dates (exams, assignments, milestones).
+ - [ ] Automated reminders for upcoming deadlines.
- [ ] **Content Library**:
- [ ] Reusable content blocks
- [ ] Template courses
@@ -105,7 +115,7 @@
- [ ] **Integration Ecosystem**:
- [ ] LTI 1.3 support
- [ ] SCORM compliance
- - [ ] Third-party integrations (Zoom, Google Meet)
+ - [ ] Third-party integrations (Zoom, Google Meet, BigBlueButton)
- [ ] **Mobile Apps**:
- [ ] Native iOS app
- [ ] Native Android app
@@ -115,11 +125,35 @@
- [ ] Screen reader optimization
- [ ] Keyboard navigation
+## Phase 8: Future Innovations (Next Gen)
+- [ ] **AI & Adaptive Learning**:
+ - [ ] **AI Tutor**: Real-time context-aware assistant for students.
+ - [ ] **Auto-grading**: LLM-based evaluation for short answers and essays.
+ - [ ] **Adaptive Paths**: Dynamic content unlocking based on performance.
+- [ ] **Monetization & Marketplace**:
+ - [ ] **Multi-tenant Payments**: Integrated Stripe/Mercado Pago per organization.
+ - [ ] **Subscriptions**: Monthly/Yearly membership support.
+ - [ ] **Promotions**: Coupons, scholarships, and referral systems.
+- [ ] **Social & Collaborative**:
+ - [ ] **Peer Review**: Structured student-to-student evaluation flows.
+ - [ ] **Co-working Spaces**: Real-time shared whiteboards and documents.
+ - [ ] **AI-Threaded Forums**: Automatic discussion summaries and sentiment analysis.
+- [ ] **Enterprise Ecosystem**:
+ - [ ] **SSO (Single Sign-On)**: Azure AD, Okta, and Google Workspace integration.
+ - [ ] **HRIS Integration**: Sync with Workday, SAP, and other HR tools.
+ - [ ] **Webhooks & API**: Extensibility for third-party automation.
+- [ ] **Deep Analytics**:
+ - [ ] **Dropout Prediction**: ML models to detect students at risk.
+ - [ ] **Engagement Heatmaps**: Detailed video and interaction tracking.
+- [ ] **Offline-First Experience**:
+ - [ ] **Mobile Offline Mode**: Encrypted downloads for learning on the go.
+
## Current Status
**Platform Maturity**: Core functionality is production-ready. Advanced features like AI integration are under active development.
**Recent Milestones**:
+- ✅ **Organization Branding**: Full customization of logos and brand colors across both portals.
- ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend.
- ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds.
- ✅ **Analytics Dashboards**: Performance insights for both instructors and students.
@@ -127,5 +161,5 @@
**Next Priorities**:
1. **AI Integration**: Implement real-time video transcription.
-2. **Gamification**: Introduce badges and achievements.
+2. **Gamification**: Expand the badges and achievement system.
3. **Advanced Analytics**: Develop cohort analysis and retention metrics.
diff --git a/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql b/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql
deleted file mode 100644
index e9341bf..0000000
--- a/services/cms-service/migrations/20250116100000_add_multi_tenancy.sql
+++ /dev/null
@@ -1,40 +0,0 @@
--- 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 UNIQUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
--- 2. Create a default organization for existing data
-INSERT INTO organizations (id, name) VALUES ('00000000-0000-0000-0000-000000000001', 'Default Organization');
-
--- 3. Add organization_id to tables with default value for existing rows
-ALTER TABLE users ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
-ALTER TABLE courses ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
-ALTER TABLE assets ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
-ALTER TABLE audit_logs ADD COLUMN organization_id UUID; -- Nullable for system logs or pre-migration logs
-
--- 4. Add Foreign Keys
-ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
-ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
-ALTER TABLE assets ADD CONSTRAINT fk_asset_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
-ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL;
-
--- 5. Remove default values for future inserts (enforce explicit organization)
-ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT;
-ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT;
-ALTER TABLE assets ALTER COLUMN organization_id DROP DEFAULT;
-
--- 6. Update Unique Constraints for Users
--- Drop the global unique email constraint (created implicitly by UNIQUE in 20231219000003_users_table.sql)
-ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
-
--- Add composite unique index scoped to organization
-CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email));
-
--- 7. Update Audit Logs to backfill organization based on user (optional best effort)
-UPDATE audit_logs SET organization_id = u.organization_id FROM users u WHERE audit_logs.user_id = u.id;
\ No newline at end of file
diff --git a/services/cms-service/migrations/20251226000000_multi_tenancy.sql b/services/cms-service/migrations/20251226000000_multi_tenancy.sql
new file mode 100644
index 0000000..ef51f3d
--- /dev/null
+++ b/services/cms-service/migrations/20251226000000_multi_tenancy.sql
@@ -0,0 +1,51 @@
+-- Migration: Add Multi-Tenancy Support (CMS)
+-- Based on existing schema: users, courses, assets, audit_logs
+
+-- 1. Create organizations table
+CREATE TABLE IF NOT EXISTS organizations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL UNIQUE,
+ 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 IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
+ALTER TABLE assets ADD COLUMN IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
+ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS organization_id UUID; -- Nullable for system logs or pre-migration logs
+
+-- 4. Add Foreign Keys (wrapped in DO block for safety)
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_user_organization') THEN
+ ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_course_organization') THEN
+ ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_asset_organization') THEN
+ ALTER TABLE assets ADD CONSTRAINT fk_asset_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_organization') THEN
+ ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL;
+ END IF;
+END $$;
+
+-- 5. Remove default values for future inserts (enforce explicit organization)
+ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT;
+ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT;
+ALTER TABLE assets ALTER COLUMN organization_id DROP DEFAULT;
+
+-- 6. Update Unique Constraints for Users
+-- Drop the global unique email constraint (created implicitly by UNIQUE in 20231219000003_users_table.sql)
+ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
+
+-- Add composite unique index scoped to organization
+CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email));
+
+-- 7. Update Audit Logs to backfill organization based on user (optional best effort)
+UPDATE audit_logs SET organization_id = u.organization_id FROM users u WHERE audit_logs.user_id = u.id;
\ No newline at end of file
diff --git a/services/cms-service/migrations/20251226000000_add_lesson_summary.sql b/services/cms-service/migrations/20251227000000_add_lesson_summary.sql
similarity index 100%
rename from services/cms-service/migrations/20251226000000_add_lesson_summary.sql
rename to services/cms-service/migrations/20251227000000_add_lesson_summary.sql
diff --git a/services/cms-service/migrations/20231229000001_add_organization_branding.sql b/services/cms-service/migrations/20251229000001_add_organization_branding.sql
similarity index 100%
rename from services/cms-service/migrations/20231229000001_add_organization_branding.sql
rename to services/cms-service/migrations/20251229000001_add_organization_branding.sql
diff --git a/services/cms-service/migrations/20251230000001_course_pacing_and_dates.sql b/services/cms-service/migrations/20251230000001_course_pacing_and_dates.sql
new file mode 100644
index 0000000..93fe17c
--- /dev/null
+++ b/services/cms-service/migrations/20251230000001_course_pacing_and_dates.sql
@@ -0,0 +1,8 @@
+-- Phase 5: Course Pacing and Dates
+
+-- Add pacing_mode to courses
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS pacing_mode VARCHAR(50) NOT NULL DEFAULT 'self_paced';
+
+-- Add due_date and important_date_type to lessons
+ALTER TABLE lessons ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
+ALTER TABLE lessons ADD COLUMN IF NOT EXISTS important_date_type VARCHAR(50);
diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs
index a635084..98fefaa 100644
--- a/services/cms-service/src/handlers.rs
+++ b/services/cms-service/src/handlers.rs
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
};
use bcrypt::{DEFAULT_COST, hash, verify};
+use chrono::{DateTime, Utc};
use common::auth::create_jwt;
use common::middleware::Org;
use common::models::{
@@ -13,6 +14,7 @@ use common::models::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
+use std::env;
use uuid::Uuid;
pub async fn publish_course(
@@ -120,15 +122,24 @@ pub async fn create_course(
.ok_or(StatusCode::BAD_REQUEST)?;
let instructor_id = claims.sub;
+ let pacing_mode = payload
+ .get("pacing_mode")
+ .and_then(|v| v.as_str())
+ .unwrap_or("self_paced");
+
let course = sqlx::query_as::<_, Course>(
- "INSERT INTO courses (title, instructor_id, organization_id) VALUES ($1, $2, $3) RETURNING *"
+ "INSERT INTO courses (title, instructor_id, organization_id, pacing_mode) VALUES ($1, $2, $3, $4) RETURNING *"
)
.bind(title)
.bind(instructor_id)
.bind(org_ctx.id)
+ .bind(pacing_mode)
.fetch_one(&pool)
.await
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ .map_err(|e| {
+ tracing::error!("Create course failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
log_action(
&pool,
@@ -136,7 +147,7 @@ pub async fn create_course(
"CREATE",
"Course",
course.id,
- json!({ "title": title }),
+ json!({ "title": title, "pacing_mode": pacing_mode }),
)
.await;
@@ -162,7 +173,6 @@ pub async fn update_course(
Path(id): Path,
Json(payload): Json,
) -> Result, (StatusCode, String)> {
- // 1. Fetch course and check ownership/role
let existing =
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id)
@@ -175,7 +185,6 @@ pub async fn update_course(
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
}
- // 2. Update fields
let title = payload
.get("title")
.and_then(|v| v.as_str())
@@ -189,22 +198,39 @@ pub async fn update_course(
.and_then(|v| v.as_i64())
.unwrap_or(existing.passing_percentage as i64) as i32;
- // Check if certificate_template is in payload (even if null to unset?)
- // For simplicity: if provided as string, use it. If not provided, keep existing.
- // To unset, user can send empty string maybe?
let certificate_template = payload
.get("certificate_template")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or(existing.certificate_template);
+ let pacing_mode = payload
+ .get("pacing_mode")
+ .and_then(|v| v.as_str())
+ .unwrap_or(&existing.pacing_mode);
+
+ let start_date = payload
+ .get("start_date")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse::>().ok())
+ .or(existing.start_date);
+
+ let end_date = payload
+ .get("end_date")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse::>().ok())
+ .or(existing.end_date);
+
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 AND organization_id = $6 RETURNING *"
+ "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, pacing_mode = $5, start_date = $6, end_date = $7, updated_at = NOW() WHERE id = $8 AND organization_id = $9 RETURNING *"
)
.bind(title)
.bind(description)
.bind(passing_percentage)
.bind(certificate_template)
+ .bind(pacing_mode)
+ .bind(start_date)
+ .bind(end_date)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
@@ -299,9 +325,16 @@ pub async fn create_lesson(
.and_then(|v| v.as_bool())
.unwrap_or(true);
+ let due_date = payload
+ .get("due_date")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse::>().ok());
+
+ let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
+
let lesson = sqlx::query_as::<_, Lesson>(
- "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"
+ "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry, due_date, important_date_type)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *"
)
.bind(module_id)
.bind(title)
@@ -314,9 +347,14 @@ pub async fn create_lesson(
.bind(grading_category_id)
.bind(max_attempts)
.bind(allow_retry)
+ .bind(due_date)
+ .bind(important_date_type)
.fetch_one(&pool)
.await
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ .map_err(|e| {
+ tracing::error!("Create lesson failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
log_action(
&pool,
@@ -337,31 +375,114 @@ pub async fn process_transcription(
Path(id): Path,
) -> Result, StatusCode> {
// 1. Fetch lesson
- let _lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
+ let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
- .map_err(|_| StatusCode::NOT_FOUND)?;
+ .map_err(|e| {
+ tracing::error!("Lesson fetch failed: {}", e);
+ StatusCode::NOT_FOUND
+ })?;
- // 2. Simulate AI Processing
- let mock_transcription = json!({
- "en": "This is a simulated transcription of the video content in English.",
- "es": "Esta es una transcripción simulada del contenido del video en español.",
- "cues": [
- { "start": 0.0, "end": 2.0, "text": "Hello world!" },
- { "start": 2.1, "end": 5.0, "text": "Welcome to OpenCCB." }
- ]
+ if lesson.content_type != "video" && lesson.content_type != "audio" {
+ return Err(StatusCode::BAD_REQUEST);
+ }
+
+ let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?;
+ let filename = url.trim_start_matches("/assets/");
+ let file_path = format!("uploads/{}", filename);
+
+ // 2. Read file
+ let file_data = tokio::fs::read(&file_path).await.map_err(|e| {
+ tracing::error!("File read failed ({}): {}", file_path, e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ // 3. Configuration
+ let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
+ let client = reqwest::Client::new();
+
+ let (url, auth_header, model) = if provider == "local" {
+ let base_url =
+ env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
+ (
+ format!("{}/v1/audio/transcriptions", base_url),
+ "".to_string(),
+ "medium".to_string(),
+ )
+ } else {
+ let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ (
+ "https://api.openai.com/v1/audio/transcriptions".to_string(),
+ format!("Bearer {}", api_key),
+ "whisper-1".to_string(),
+ )
+ };
+
+ let part = reqwest::multipart::Part::bytes(file_data)
+ .file_name(filename.to_string())
+ .mime_str("application/octet-stream")
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let form = reqwest::multipart::Form::new()
+ .part("file", part)
+ .text("model", model)
+ .text("response_format", "verbose_json");
+
+ let mut request = client.post(&url).multipart(form);
+ if !auth_header.is_empty() {
+ request = request.header("Authorization", auth_header);
+ }
+
+ let response = request.send().await.map_err(|e| {
+ tracing::error!("Transcription request failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ if !response.status().is_success() {
+ let err_body = response.text().await.unwrap_or_default();
+ tracing::error!("Transcription API error: {}", err_body);
+ return Err(StatusCode::INTERNAL_SERVER_ERROR);
+ }
+
+ let whisper_data: serde_json::Value = response.json().await.map_err(|e| {
+ tracing::error!("Whisper JSON parse failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ // Extract text and segments (cues)
+ let text = whisper_data["text"].as_str().unwrap_or_default();
+ let segments = whisper_data["segments"].as_array();
+
+ let mut cues = Vec::new();
+ if let Some(segments) = segments {
+ for s in segments {
+ cues.push(json!({
+ "start": s["start"],
+ "end": s["end"],
+ "text": s["text"]
+ }));
+ }
+ }
+
+ let transcription = json!({
+ "en": text,
+ "es": "", // Could add a translation step here
+ "cues": cues
});
- // 3. Update lesson
+ // 4. Update lesson
let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *",
)
- .bind(mock_transcription)
+ .bind(transcription)
.bind(id)
.fetch_one(&pool)
.await
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ .map_err(|e| {
+ tracing::error!("Database update failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
log_action(
&pool,
@@ -388,17 +509,84 @@ pub async fn summarize_lesson(
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
- // 2. Simulate AI Summarization based on content
- // In a real scenario, this would call an LLM with the transcription or blocks content
- let mock_summary = format!(
- "This lesson, titled '{}', covers the fundamental concepts of the topic. It includes interactive elements designed to reinforce learning through practice and assessment.",
- lesson.title
- );
+ let transcription_text = lesson
+ .transcription
+ .as_ref()
+ .and_then(|t| t["en"].as_str())
+ .unwrap_or("");
+
+ if transcription_text.is_empty() {
+ tracing::warn!("Cannot summarize lesson {}: No transcription found", id);
+ return Err(StatusCode::BAD_REQUEST);
+ }
+
+ // 2. Configuration
+ let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
+ let client = reqwest::Client::new();
+
+ let (url, auth_header, model) = if provider == "local" {
+ let base_url =
+ env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
+ let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
+ (
+ format!("{}/v1/chat/completions", base_url),
+ "".to_string(),
+ model,
+ )
+ } else {
+ let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ (
+ "https://api.openai.com/v1/chat/completions".to_string(),
+ format!("Bearer {}", api_key),
+ "gpt-4o".to_string(),
+ )
+ };
+
+ let mut request = client
+ .post(&url)
+ .json(&json!({
+ "model": model,
+ "messages": [
+ {
+ "role": "system",
+ "content": "You are a professional educational assistant. Summarize the following lesson transcription into a high-quality summary suited for a course platform. Keep it concise but informative (max 150 words). Focus on the key learning objectives."
+ },
+ {
+ "role": "user",
+ "content": transcription_text
+ }
+ ]
+ }));
+
+ if !auth_header.is_empty() {
+ request = request.header("Authorization", auth_header);
+ }
+
+ let response = request.send().await.map_err(|e| {
+ tracing::error!("Summarization request failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ if !response.status().is_success() {
+ let err_body = response.text().await.unwrap_or_default();
+ tracing::error!("Summarization API error: {}", err_body);
+ return Err(StatusCode::INTERNAL_SERVER_ERROR);
+ }
+
+ let gpt_data: serde_json::Value = response.json().await.map_err(|e| {
+ tracing::error!("Summarization JSON parse failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ let summary = gpt_data["choices"][0]["message"]["content"]
+ .as_str()
+ .unwrap_or("")
+ .trim();
// 3. Update lesson
let updated_lesson =
sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *")
- .bind(mock_summary)
+ .bind(summary)
.bind(id)
.fetch_one(&pool)
.await
@@ -429,27 +617,90 @@ pub async fn generate_quiz(
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
- // 2. Simulate AI Quiz Generation
- // Normally would use lesson content (transcription, blocks, etc.)
- let quiz_blocks = json!([
- {
- "id": Uuid::new_v4().to_string(),
- "type": "quiz",
- "title": "Automated Content Check",
- "quiz_data": {
- "questions": [
- {
- "id": "q1",
- "type": "multiple-choice",
- "question": format!("Based on '{}', what is the primary objective?", lesson.title),
- "options": ["Option A", "Option B", "Option C", "Option D"],
- "correctAnswer": 0,
- "explanation": "This question was generated automatically based on the lesson title."
- }
- ]
- }
- }
- ]);
+ let transcription_text = lesson
+ .transcription
+ .as_ref()
+ .and_then(|t| t["en"].as_str())
+ .unwrap_or("");
+
+ if transcription_text.is_empty() {
+ tracing::warn!(
+ "Cannot generate quiz for lesson {}: No transcription found",
+ id
+ );
+ return Err(StatusCode::BAD_REQUEST);
+ }
+
+ // 2. Configuration
+ let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
+ let client = reqwest::Client::new();
+
+ let (url, auth_header, model) = if provider == "local" {
+ let base_url =
+ env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
+ let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
+ (
+ format!("{}/v1/chat/completions", base_url),
+ "".to_string(),
+ model,
+ )
+ } else {
+ let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ (
+ "https://api.openai.com/v1/chat/completions".to_string(),
+ format!("Bearer {}", api_key),
+ "gpt-4o".to_string(),
+ )
+ };
+
+ let mut request = client
+ .post(&url)
+ .json(&json!({
+ "model": model,
+ "messages": [
+ {
+ "role": "system",
+ "content": "You are an educational content designer. Generate 3 multiple-choice questions based on the lesson transcription. Return ONLY a JSON object with a field 'blocks' which is an array. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correctAnswer\": 0, \"explanation\": \"Explain why the answer is correct.\" } ] } }"
+ },
+ {
+ "role": "user",
+ "content": transcription_text
+ }
+ ],
+ "response_format": { "type": "json_object" }
+ }));
+
+ if !auth_header.is_empty() {
+ request = request.header("Authorization", auth_header);
+ }
+
+ let response = request.send().await.map_err(|e| {
+ tracing::error!("Quiz generation request failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ if !response.status().is_success() {
+ let err_body = response.text().await.unwrap_or_default();
+ tracing::error!("Quiz API error: {}", err_body);
+ return Err(StatusCode::INTERNAL_SERVER_ERROR);
+ }
+
+ let quiz_data: serde_json::Value = response
+ .json()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let quiz_json_str = quiz_data["choices"][0]["message"]["content"]
+ .as_str()
+ .unwrap_or("{}");
+
+ let mut quiz_data_parsed: serde_json::Value =
+ serde_json::from_str(quiz_json_str).unwrap_or(json!({}));
+
+ // Ensure we return just the blocks array as the frontend expects
+ let quiz_blocks = quiz_data_parsed
+ .get_mut("blocks")
+ .cloned()
+ .unwrap_or(json!([]));
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
@@ -492,6 +743,7 @@ pub async fn update_lesson(
.map(|v| v as i32);
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
let metadata = payload.get("metadata");
+ let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons
@@ -504,8 +756,10 @@ pub async fn update_lesson(
metadata = COALESCE($8, metadata),
max_attempts = COALESCE($9, max_attempts),
allow_retry = COALESCE($10, allow_retry),
- summary = COALESCE($11, summary)
- WHERE id = $12 RETURNING *"
+ summary = COALESCE($11, summary),
+ due_date = CASE WHEN $12 = 'SET_NULL' THEN NULL WHEN $13::TIMESTAMPTZ IS NOT NULL THEN $13 ELSE due_date END,
+ important_date_type = COALESCE($14, important_date_type)
+ WHERE id = $15 RETURNING *"
)
.bind(title)
.bind(content_type)
@@ -518,6 +772,9 @@ pub async fn update_lesson(
.bind(max_attempts)
.bind(allow_retry)
.bind(payload.get("summary").and_then(|v| v.as_str()))
+ .bind(if payload.get("due_date").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
+ .bind(payload.get("due_date").and_then(|v| v.as_str()).and_then(|s| s.parse::>().ok()))
+ .bind(important_date_type)
.bind(id)
.fetch_one(&pool)
.await
@@ -668,6 +925,57 @@ pub async fn get_lessons(
Ok(Json(lessons))
}
+#[derive(Deserialize)]
+pub struct ReorderPayload {
+ pub items: Vec,
+}
+
+#[derive(Deserialize)]
+pub struct ReorderItem {
+ pub id: Uuid,
+ pub position: i32,
+}
+
+pub async fn reorder_modules(
+ _claims: common::auth::Claims,
+ State(pool): State,
+ Json(payload): Json,
+) -> Result {
+ for item in payload.items {
+ sqlx::query("UPDATE modules SET position = $1 WHERE id = $2")
+ .bind(item.position)
+ .bind(item.id)
+ .execute(&pool)
+ .await
+ .map_err(|e| {
+ tracing::error!("Reorder modules failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+ }
+
+ Ok(StatusCode::OK)
+}
+
+pub async fn reorder_lessons(
+ _claims: common::auth::Claims,
+ State(pool): State,
+ Json(payload): Json,
+) -> Result {
+ for item in payload.items {
+ sqlx::query("UPDATE lessons SET position = $1 WHERE id = $2")
+ .bind(item.position)
+ .bind(item.id)
+ .execute(&pool)
+ .await
+ .map_err(|e| {
+ tracing::error!("Reorder lessons failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+ }
+
+ Ok(StatusCode::OK)
+}
+
#[derive(Debug, Serialize)]
pub struct UploadResponse {
pub id: Uuid,
@@ -840,6 +1148,7 @@ pub async fn register(
email: user.email,
full_name: user.full_name,
role: user.role,
+ organization_id: user.organization_id,
},
token,
}))
@@ -877,6 +1186,7 @@ pub async fn login(
email: user.email,
full_name: user.full_name,
role: user.role,
+ organization_id: user.organization_id,
},
token,
}))
@@ -1073,3 +1383,94 @@ pub async fn update_module(
Ok(Json(updated_module))
}
+
+pub async fn delete_module(
+ claims: common::auth::Claims,
+ State(pool): State,
+ Path(id): Path,
+) -> Result {
+ sqlx::query("DELETE FROM modules WHERE id = $1")
+ .bind(id)
+ .execute(&pool)
+ .await
+ .map_err(|e| {
+ tracing::error!("Delete module failed: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ log_action(&pool, claims.sub, "DELETE", "Module", id, json!({})).await;
+
+ Ok(StatusCode::OK)
+}
+
+pub async fn delete_lesson(
+ claims: common::auth::Claims,
+ State(pool): State,
+ Path(id): Path,
+) -> Result {
+ if claims.role != "admin" {
+ return Err(StatusCode::FORBIDDEN);
+ }
+ sqlx::query("DELETE FROM lessons WHERE id = $1")
+ .bind(id)
+ .execute(&pool)
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ log_action(&pool, claims.sub, "DELETE_LESSON", "Lesson", id, json!({})).await;
+
+ Ok(StatusCode::OK)
+}
+
+// User Management
+pub async fn get_all_users(
+ claims: common::auth::Claims,
+ State(pool): State,
+) -> Result>, StatusCode> {
+ if claims.role != "admin" {
+ return Err(StatusCode::FORBIDDEN);
+ }
+
+ let users = sqlx::query_as::<_, UserResponse>(
+ "SELECT id, email, full_name, role, organization_id FROM users",
+ )
+ .fetch_all(&pool)
+ .await
+ .map_err(|e| {
+ tracing::error!("Failed to fetch users: {}", e);
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ Ok(Json(users))
+}
+
+pub async fn update_user(
+ claims: common::auth::Claims,
+ State(pool): State,
+ Path(id): Path,
+ Json(payload): Json,
+) -> Result {
+ if claims.role != "admin" {
+ return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
+ }
+
+ let role = payload.get("role").and_then(|r| r.as_str());
+ let organization_id = payload
+ .get("organization_id")
+ .and_then(|o| o.as_str())
+ .and_then(|o| Uuid::parse_str(o).ok());
+
+ sqlx::query(
+ "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3"
+ )
+ .bind(role)
+ .bind(organization_id)
+ .bind(id)
+ .execute(&pool)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ log_action(&pool, claims.sub, "UPDATE_USER", "User", id, payload).await;
+
+ Ok(StatusCode::OK)
+}
diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs
index a23545c..593ea90 100644
--- a/services/cms-service/src/main.rs
+++ b/services/cms-service/src/main.rs
@@ -54,14 +54,21 @@ async fn main() {
"/modules",
get(handlers::get_modules).post(handlers::create_module),
)
- .route("/modules/{id}", axum::routing::put(handlers::update_module))
+ .route("/modules/reorder", post(handlers::reorder_modules))
+ .route(
+ "/modules/{id}",
+ axum::routing::put(handlers::update_module).delete(handlers::delete_module),
+ )
.route(
"/lessons",
get(handlers::get_lessons).post(handlers::create_lesson),
)
+ .route("/lessons/reorder", post(handlers::reorder_lessons))
.route(
"/lessons/{id}",
- get(handlers::get_lesson).put(handlers::update_lesson),
+ get(handlers::get_lesson)
+ .put(handlers::update_lesson)
+ .delete(handlers::delete_lesson),
)
.route(
"/lessons/{id}/transcribe",
@@ -75,15 +82,17 @@ async fn main() {
"/courses/{id}/grading",
get(handlers::get_grading_categories),
)
+ .route("/users", get(handlers::get_all_users))
+ .route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/audit-logs", get(handlers::get_audit_logs))
.route("/assets/upload", post(handlers::upload_asset))
.route("/organization", get(handlers::get_organization))
.route(
- "/organizations/:id/logo",
+ "/organizations/{id}/logo",
post(handlers_branding::upload_organization_logo),
)
.route(
- "/organizations/:id/branding",
+ "/organizations/{id}/branding",
axum::routing::put(handlers_branding::update_organization_branding),
)
.route_layer(middleware::from_fn(
@@ -95,7 +104,7 @@ async fn main() {
.route("/auth/register", post(handlers::register))
.route("/auth/login", post(handlers::login))
.route(
- "/organizations/:id/branding",
+ "/organizations/{id}/branding",
get(handlers_branding::get_organization_branding),
)
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
diff --git a/services/lms-service/migrations/20231219000001_enrollments.sql b/services/lms-service/migrations/20231219000001_enrollments.sql
index 41f2359..a6f95c2 100644
--- a/services/lms-service/migrations/20231219000001_enrollments.sql
+++ b/services/lms-service/migrations/20231219000001_enrollments.sql
@@ -3,7 +3,7 @@ CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
course_id UUID NOT NULL, -- Referenced by ID from CMS service
- enroled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Note: In a real microservices scenario, courses might be synced from CMS or shared DB.
diff --git a/services/lms-service/migrations/20250116100000_add_multi_tenancy.sql b/services/lms-service/migrations/20231226000001_add_multi_tenancy.sql
similarity index 100%
rename from services/lms-service/migrations/20250116100000_add_multi_tenancy.sql
rename to services/lms-service/migrations/20231226000001_add_multi_tenancy.sql
diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs
index fba272d..174381d 100644
--- a/shared/common/src/models.rs
+++ b/shared/common/src/models.rs
@@ -1,5 +1,6 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use serde_json;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
@@ -9,6 +10,7 @@ pub struct Course {
pub title: String,
pub description: Option,
pub instructor_id: Uuid,
+ pub pacing_mode: String, // "self_paced" or "instructor_led"
pub start_date: Option>,
pub end_date: Option>,
pub passing_percentage: i32,
@@ -41,6 +43,8 @@ pub struct Lesson {
pub max_attempts: Option,
pub allow_retry: bool,
pub position: i32,
+ pub due_date: Option>,
+ pub important_date_type: Option, // "exam", "assignment", "milestone", etc.
pub created_at: DateTime,
}
@@ -96,7 +100,7 @@ pub struct Enrollment {
pub user_id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
- pub enroled_at: DateTime,
+ pub enrolled_at: DateTime,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
@@ -121,12 +125,13 @@ pub struct User {
pub created_at: DateTime,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub full_name: String,
pub role: String,
+ pub organization_id: Uuid,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
@@ -192,6 +197,7 @@ mod tests {
title: "Test Lesson".to_string(),
content_type: "activity".to_string(),
content_url: None,
+ summary: None,
transcription: None,
metadata: Some(json!({
"blocks": [
@@ -212,6 +218,8 @@ mod tests {
max_attempts: None,
allow_retry: true,
position: 1,
+ due_date: None,
+ important_date_type: None,
created_at: Utc::now(),
};
@@ -229,9 +237,11 @@ mod tests {
let pub_course = PublishedCourse {
course: Course {
id: course_id,
+ organization_id: Uuid::new_v4(),
title: "Test Course".to_string(),
description: None,
instructor_id: Uuid::new_v4(),
+ pacing_mode: "self_paced".to_string(),
start_date: None,
end_date: None,
passing_percentage: 70,
diff --git a/web/experience/package-lock.json b/web/experience/package-lock.json
index f475826..a7b22b4 100644
--- a/web/experience/package-lock.json
+++ b/web/experience/package-lock.json
@@ -521,6 +521,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -577,6 +578,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1051,6 +1053,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1451,6 +1454,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2056,6 +2060,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2218,6 +2223,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3514,6 +3520,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4226,6 +4233,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4416,6 +4424,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4427,6 +4436,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5267,6 +5277,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -5424,6 +5435,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/web/experience/src/app/courses/[id]/calendar/page.tsx b/web/experience/src/app/courses/[id]/calendar/page.tsx
new file mode 100644
index 0000000..6422c9f
--- /dev/null
+++ b/web/experience/src/app/courses/[id]/calendar/page.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { lmsApi, Course, Lesson, Module } from "@/lib/api";
+import Link from "next/link";
+import {
+ Calendar as CalendarIcon,
+ ChevronLeft,
+ ChevronRight,
+ ChevronRight as ChevronRightIcon,
+ AlertCircle,
+ Clock,
+ CheckCircle2
+} from "lucide-react";
+
+export default function StudentCalendarPage({ params }: { params: { id: string } }) {
+ const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
+ const [lessons, setLessons] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [currentDate, setCurrentDate] = useState(new Date());
+
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ const courseData = await lmsApi.getCourseOutline(params.id);
+ setCourse(courseData);
+
+ // Flatten lessons from modules
+ const allLessons: Lesson[] = [];
+ courseData.modules?.forEach(mod => {
+ mod.lessons.forEach(lesson => {
+ allLessons.push(lesson);
+ });
+ });
+ setLessons(allLessons);
+ } catch (err) {
+ console.error("Failed to load course data", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ loadData();
+ }, [params.id]);
+
+ const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
+ const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
+
+ const renderCalendar = () => {
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth();
+ const daysInMonth = getDaysInMonth(year, month);
+ const firstDay = getFirstDayOfMonth(year, month);
+ const days = [];
+
+ // Padding
+ for (let i = 0; i < firstDay; i++) {
+ days.push();
+ }
+
+ // Days
+ for (let day = 1; day <= daysInMonth; day++) {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
+ const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
+
+ days.push(
+
+
+ {day}
+ {isToday && Today}
+
+
+ {dayLessons.map(lesson => (
+
+
+
+ {lesson.title}
+
+
+ ))}
+
+
+ );
+ }
+
+ return days;
+ };
+
+ const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
+ const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
+
+ if (loading) return Syncing your timeline...
;
+ if (!course) return Course not found.
;
+
+ const monthName = currentDate.toLocaleString('default', { month: 'long' });
+ const year = currentDate.getFullYear();
+
+ return (
+
+
+
+
+ Outline
+
+ Timeline
+
+
Course Timeline
+
{course.title}
+
+
+
+
+
+
+
+
+
+
{monthName} {year}
+
+
+
+
+
+
+
+
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
+
+ {day}
+
+ ))}
+ {renderCalendar()}
+
+
+
+
+
+
+
+
+ Upcoming Deadlines
+
+
+ {lessons
+ .filter(l => l.due_date && new Date(l.due_date) >= new Date())
+ .sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
+ .slice(0, 5)
+ .map(lesson => (
+
+
+ {lesson.important_date_type || 'Activity'}
+ {new Date(lesson.due_date!).toLocaleDateString()}
+
+
{lesson.title}
+
+ ))
+ }
+ {lessons.filter(l => l.due_date && new Date(l.due_date) >= new Date()).length === 0 && (
+
No upcoming deadlines. You are all caught up!
+ )}
+
+
+
+
+
+
+
+ Course Pace
+
+
+
+ Mode
+ {course.pacing_mode.replace('_', '-')}
+
+ {course.start_date && (
+
+ Start Date
+ {new Date(course.start_date).toLocaleDateString()}
+
+ )}
+ {course.end_date && (
+
+ End Date
+ {new Date(course.end_date).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx
index 6c63e4a..d81ad67 100644
--- a/web/experience/src/app/courses/[id]/page.tsx
+++ b/web/experience/src/app/courses/[id]/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { lmsApi, Course, Module } from "@/lib/api";
import Link from "next/link";
-import { BookOpen, ChevronRight, PlayCircle } from "lucide-react";
+import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
@@ -45,6 +45,25 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
{courseData.description || "Master the core principles and advanced techniques in this structured curriculum. Each module is designed to provide actionable insights and hands-on experience."}
+
+
+ {courseData.pacing_mode === 'instructor_led' ? : }
+ {courseData.pacing_mode === 'instructor_led' ? 'Instructor-Led' : 'Self-Paced'}
+
+
+ {courseData.pacing_mode === 'instructor_led' && (courseData.start_date || courseData.end_date) && (
+
+
+
+ {courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'TBD'}
+ →
+ {courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'TBD'}
+
+
+ )}
+
+
@@ -60,11 +79,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
-
-
-
+
+
+
+
+
+
+
+
@@ -98,8 +124,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
-
-
+
+ {lesson.due_date && (
+
+
Deadline
+
+ {new Date(lesson.due_date).toLocaleDateString()}
+
+
+ )}
+
+
+
diff --git a/web/experience/src/app/globals.css b/web/experience/src/app/globals.css
index dd9f978..2f5f79d 100644
--- a/web/experience/src/app/globals.css
+++ b/web/experience/src/app/globals.css
@@ -7,8 +7,12 @@
--background-start-rgb: 10, 10, 20;
--background-end-rgb: 0, 0, 0;
- --accent-primary: #3b82f6;
- --accent-secondary: #8b5cf6;
+ /* Branding Defaults */
+ --primary-color: #3b82f6;
+ --secondary-color: #8b5cf6;
+
+ --accent-primary: var(--primary-color);
+ --accent-secondary: var(--secondary-color);
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(16px);
diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx
index 35cfca0..b2e0274 100644
--- a/web/experience/src/app/layout.tsx
+++ b/web/experience/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { AuthProvider } from "@/context/AuthContext";
+import { BrandingProvider, useBranding } from "@/context/BrandingContext";
const inter = Inter({ subsets: ["latin"] });
@@ -11,6 +12,36 @@ export const metadata: Metadata = {
description: "Consume high-fidelity educational content with OpenCCB",
};
+function AppHeader() {
+ const { branding } = useBranding();
+
+ return (
+
+ );
+}
+
export default function RootLayout({
children,
}: Readonly<{
@@ -19,34 +50,19 @@ export default function RootLayout({
return (
-
- {/* Header */}
-
-
-
- L
-
- LEARNEXPERIENCE
-
-
-
-
-
-
- {children}
-
-
- {/* Footer */}
-
-
+
+
+
+
+ {children}
+
+
+
+
);
diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx
index d835665..030cfcd 100644
--- a/web/experience/src/app/page.tsx
+++ b/web/experience/src/app/page.tsx
@@ -1,17 +1,18 @@
"use client";
import { useEffect, useState } from "react";
-import { lmsApi, Course } from "@/lib/api";
+import { lmsApi, Course, Lesson } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
-import { Rocket, CheckCircle2, ArrowRight, Star } from "lucide-react";
+import { Rocket, CheckCircle2, ArrowRight, Star, Calendar, Clock, AlertCircle } from "lucide-react";
export default function CatalogPage() {
const [courses, setCourses] = useState([]);
const [enrollments, setEnrollments] = useState([]);
const [loading, setLoading] = useState(true);
const [gamification, setGamification] = useState<{ points: number, badges: any[] } | null>(null);
+ const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string }[]>([]);
const { user } = useAuth();
const router = useRouter();
@@ -28,6 +29,24 @@ export default function CatalogPage() {
const gamificationData = await lmsApi.getGamification(user.id);
setGamification(gamificationData);
+
+ // Fetch deadlines for enrolled courses
+ const deadlines: { lesson: Lesson, courseTitle: string }[] = [];
+ for (const enrollment of enrollmentData) {
+ try {
+ const outline = await lmsApi.getCourseOutline(enrollment.course_id);
+ outline.modules.forEach(mod => {
+ mod.lessons.forEach(l => {
+ if (l.due_date && new Date(l.due_date) >= new Date()) {
+ deadlines.push({ lesson: l, courseTitle: outline.title });
+ }
+ });
+ });
+ } catch (err) {
+ console.error(`Failed to fetch outline for course ${enrollment.course_id}`, err);
+ }
+ }
+ setUpcomingDeadlines(deadlines.sort((a, b) => new Date(a.lesson.due_date!).getTime() - new Date(b.lesson.due_date!).getTime()).slice(0, 3));
}
} catch (err) {
console.error(err);
@@ -115,7 +134,33 @@ export default function CatalogPage() {
)}
{/* Visual Flair */}
-
+
+
+ )}
+
+ {user && upcomingDeadlines.length > 0 && (
+
+
+ Upcoming Deadlines
+
+
+ {upcomingDeadlines.map(({ lesson, courseTitle }) => (
+
+
+
+
+ {lesson.important_date_type || 'Activity'}
+
+
+
{new Date(lesson.due_date!).toLocaleDateString()}
+
Deadline
+
+
+
{lesson.title}
+
{courseTitle}
+
+
+ ))}
)}
diff --git a/web/experience/src/context/BrandingContext.tsx b/web/experience/src/context/BrandingContext.tsx
new file mode 100644
index 0000000..6dab2db
--- /dev/null
+++ b/web/experience/src/context/BrandingContext.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { lmsApi, Organization } from '@/lib/api';
+
+interface BrandingContextType {
+ branding: Organization | null;
+ loading: boolean;
+}
+
+const BrandingContext = createContext({
+ branding: null,
+ loading: true,
+});
+
+export const useBranding = () => useContext(BrandingContext);
+
+export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [branding, setBranding] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const orgId = process.env.NEXT_PUBLIC_ORG_ID || '00000000-0000-0000-0000-000000000001';
+
+ useEffect(() => {
+ const loadBranding = async () => {
+ try {
+ const data = await lmsApi.getBranding(orgId);
+ setBranding(data);
+
+ // Apply CSS variables
+ if (data.primary_color) {
+ document.documentElement.style.setProperty('--primary-color', data.primary_color);
+ }
+ if (data.secondary_color) {
+ document.documentElement.style.setProperty('--secondary-color', data.secondary_color);
+ }
+ } catch (error) {
+ console.error('Failed to load branding', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadBranding();
+ }, [orgId]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts
index 854772a..e9e0e84 100644
--- a/web/experience/src/lib/api.ts
+++ b/web/experience/src/lib/api.ts
@@ -1,4 +1,13 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
+export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
+
+export interface Organization {
+ id: string;
+ name: string;
+ logo_url?: string;
+ primary_color?: string;
+ secondary_color?: string;
+}
export interface Course {
id: string;
@@ -7,6 +16,9 @@ export interface Course {
instructor_id: string;
passing_percentage: number;
certificate_template?: string;
+ pacing_mode: string;
+ start_date?: string;
+ end_date?: string;
created_at: string;
}
@@ -55,6 +67,8 @@ export interface Lesson {
max_attempts: number | null;
allow_retry: boolean;
position: number;
+ due_date?: string;
+ important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
created_at: string;
}
@@ -177,5 +191,11 @@ export const lmsApi = {
const response = await fetch(`${API_BASE_URL}/users/${userId}/gamification`);
if (!response.ok) throw new Error('Failed to fetch gamification data');
return response.json();
+ },
+
+ async getBranding(orgId: string): Promise {
+ const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`);
+ if (!response.ok) throw new Error('Failed to fetch branding');
+ return response.json();
}
};
diff --git a/web/studio/package-lock.json b/web/studio/package-lock.json
index 9a0f6bf..ad68ba4 100644
--- a/web/studio/package-lock.json
+++ b/web/studio/package-lock.json
@@ -545,6 +545,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -606,6 +607,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1080,6 +1082,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2000,6 +2003,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2162,6 +2166,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3445,6 +3450,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4151,6 +4157,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4346,6 +4353,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4357,6 +4365,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4417,7 +4426,8 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
- "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "peer": true
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -5229,6 +5239,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -5386,6 +5397,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/web/studio/src/app/admin/organizations/page.tsx b/web/studio/src/app/admin/organizations/page.tsx
index 48824e4..be410a6 100644
--- a/web/studio/src/app/admin/organizations/page.tsx
+++ b/web/studio/src/app/admin/organizations/page.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { cmsApi, Organization } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
-import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck } from 'lucide-react';
+import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Check } from 'lucide-react';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState([]);
@@ -11,6 +11,15 @@ export default function OrganizationsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newDomain, setNewDomain] = useState('');
+
+ // Branding States
+ const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false);
+ const [selectedOrg, setSelectedOrg] = useState(null);
+ const [primaryColor, setPrimaryColor] = useState('#3B82F6');
+ const [secondaryColor, setSecondaryColor] = useState('#8B5CF6');
+ const [isSavingBranding, setIsSavingBranding] = useState(false);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+
const { user } = useAuth();
useEffect(() => {
@@ -41,6 +50,51 @@ export default function OrganizationsPage() {
}
};
+ const openBranding = (org: Organization) => {
+ setSelectedOrg(org);
+ setPrimaryColor(org.primary_color || '#3B82F6');
+ setSecondaryColor(org.secondary_color || '#8B5CF6');
+ setIsBrandingModalOpen(true);
+ };
+
+ const handleLogoUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file || !selectedOrg) return;
+
+ setUploadingLogo(true);
+ try {
+ const resp = await cmsApi.uploadOrganizationLogo(selectedOrg.id, file);
+ setSelectedOrg({ ...selectedOrg, logo_url: resp.url });
+ // Update in list
+ setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, logo_url: resp.url } : o));
+ } catch (error) {
+ console.error('Failed to upload logo', error);
+ alert('Failed to upload logo. Please try again.');
+ } finally {
+ setUploadingLogo(false);
+ }
+ };
+
+ const handleBrandingSave = async () => {
+ if (!selectedOrg) return;
+
+ setIsSavingBranding(true);
+ try {
+ await cmsApi.updateOrganizationBranding(selectedOrg.id, {
+ primary_color: primaryColor,
+ secondary_color: secondaryColor
+ });
+ // Update in list
+ setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, primary_color: primaryColor, secondary_color: secondaryColor } : o));
+ setIsBrandingModalOpen(false);
+ } catch (error) {
+ console.error('Failed to update branding', error);
+ alert('Failed to update branding. Please try again.');
+ } finally {
+ setIsSavingBranding(false);
+ }
+ };
+
if (user?.role !== 'admin') {
return (
@@ -87,8 +141,12 @@ export default function OrganizationsPage() {
-
-
+
+ {org.logo_url ? (
+

+ ) : (
+
+ )}
{org.name}
@@ -99,7 +157,12 @@ export default function OrganizationsPage() {
-
+
+
+
@@ -109,16 +172,24 @@ export default function OrganizationsPage() {
{org.id.split('-')[0]}...
-
+
+
+
+
))}
)}
- {/* Modal */}
+ {/* Create Organization Modal */}
{isModalOpen && (
@@ -164,6 +235,146 @@ export default function OrganizationsPage() {
)}
+
+ {/* Branding Management Modal */}
+ {isBrandingModalOpen && selectedOrg && (
+
+
+
+
+
Branding Management
+
{selectedOrg.name}
+
+
+
+
+
+
+ {/* Logo Upload */}
+
+
+
+
+ {selectedOrg.logo_url ? (
+

+ ) : (
+
+ )}
+
+
+
+
PNG, JPG or SVG. Max 2MB.
+
+
+
+
+ {/* Colors */}
+
+
+
+ {/* Live Preview */}
+
+
+
+ {/* Mock Experience Header */}
+
+
+
+ {selectedOrg.logo_url ? (
+

+ ) :
}
+
+
+
+
+
+ {/* Mock Experience Content */}
+
+
+
+
+ This is a real-time preview of how the brand identity will apply to the student's learning experience.
+
+
+
+
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/web/studio/src/app/courses/[id]/calendar/page.tsx b/web/studio/src/app/courses/[id]/calendar/page.tsx
new file mode 100644
index 0000000..1b86f23
--- /dev/null
+++ b/web/studio/src/app/courses/[id]/calendar/page.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { cmsApi, Course, Lesson } from "@/lib/api";
+import Link from "next/link";
+import {
+ Calendar as CalendarIcon,
+ ChevronLeft,
+ ChevronRight,
+ Plus,
+ Layout,
+ CheckCircle2,
+ BarChart2,
+ Settings,
+ Clock,
+ AlertCircle
+} from "lucide-react";
+
+export default function CourseCalendarPage({ params }: { params: { id: string } }) {
+ const [course, setCourse] = useState(null);
+ const [lessons, setLessons] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [currentDate, setCurrentDate] = useState(new Date());
+
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ const courseData = await cmsApi.getCourseWithFullOutline(params.id);
+ setCourse(courseData);
+
+ // Flatten lessons from modules
+ const allLessons: Lesson[] = [];
+ courseData.modules?.forEach(mod => {
+ mod.lessons.forEach(lesson => {
+ allLessons.push(lesson);
+ });
+ });
+ setLessons(allLessons);
+ } catch (err) {
+ console.error("Failed to load course data", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ loadData();
+ }, [params.id]);
+
+ const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
+ const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
+
+ const renderCalendar = () => {
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth();
+ const daysInMonth = getDaysInMonth(year, month);
+ const firstDay = getFirstDayOfMonth(year, month);
+ const days = [];
+
+ // Padding for first week
+ for (let i = 0; i < firstDay; i++) {
+ days.push();
+ }
+
+ // Days of month
+ for (let day = 1; day <= daysInMonth; day++) {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
+
+ days.push(
+
+
{day}
+
+ {dayLessons.map(lesson => (
+
+
+ {lesson.title}
+
+ ))}
+
+
+ );
+ }
+
+ return days;
+ };
+
+ const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
+ const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
+
+ if (loading) return Loading calendar...
;
+
+ const monthName = currentDate.toLocaleString('default', { month: 'long' });
+ const year = currentDate.getFullYear();
+
+ return (
+
+
+ Courses
+ /
+ {course?.title}
+
+
+
+
+
{course?.title}
+
+
+ Course Calendar
+
+
+
+
+ Back to Outline
+
+
+
+
+
+
+
+ Outline
+
+
+ Grading
+
+
+ Calendar
+
+
+ Analytics
+
+
+ Settings
+
+
+
+
+
+
+
{monthName} {year}
+
+
+
+
+
+
+
+
+
+ Exam
+
+
+ Assignment
+
+
+ Live
+
+
+ Lesson
+
+
+
+
+
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
+
+ {day}
+
+ ))}
+ {renderCalendar()}
+
+
+
+
+
+ Upcoming Deadlines
+
+
+ {lessons
+ .filter(l => l.due_date && new Date(l.due_date) >= new Date())
+ .sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
+ .slice(0, 6)
+ .map(lesson => (
+
+
+
+
+ {lesson.important_date_type || 'Activity'}
+
+
{lesson.title}
+
+
+
{new Date(lesson.due_date!).toLocaleDateString()}
+
Due Date
+
+
+
+ ))
+ }
+
+
+
+
+
+ );
+}
diff --git a/web/studio/src/app/courses/[id]/grading/page.tsx b/web/studio/src/app/courses/[id]/grading/page.tsx
index 4d209bb..cc41873 100644
--- a/web/studio/src/app/courses/[id]/grading/page.tsx
+++ b/web/studio/src/app/courses/[id]/grading/page.tsx
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
-import { cmsApi, GradingCategory } from "@/lib/api";
+import { cmsApi, GradingCategory, Course } from "@/lib/api";
import {
Plus,
Trash2,
@@ -11,8 +11,12 @@ import {
CheckCircle2,
ArrowLeft,
TrendingUp,
- Settings
+ Settings,
+ Layout,
+ Calendar,
+ BarChart2
} from "lucide-react";
+import Link from "next/link";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -109,6 +113,26 @@ export default function GradingPolicyPage() {
+
+
+
+ Outline
+
+
+ Grading
+
+
+ Calendar
+
+
+ Analytics
+
+
+ Settings
+
+
+
+
{/* Categories List */}
diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx
index 78101ca..1628f7e 100644
--- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx
+++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx
@@ -10,6 +10,20 @@ import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock";
import MatchingBlock from "@/components/blocks/MatchingBlock";
import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
+import {
+ Save,
+ X,
+ Pencil,
+ ChevronUp,
+ ChevronDown,
+ Trash2,
+ PlayCircle,
+ FileText,
+ Calendar,
+ Settings,
+ Layout,
+ CheckCircle2
+} from "lucide-react";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState(null);
@@ -20,6 +34,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Activity State (Blocks)
const [blocks, setBlocks] = useState([]);
const [summary, setSummary] = useState("");
+ const [isTranscribing, setIsTranscribing] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
const [gradingCategories, setGradingCategories] = useState([]);
@@ -27,6 +42,11 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [selectedCategoryId, setSelectedCategoryId] = useState("");
const [maxAttempts, setMaxAttempts] = useState(null);
const [allowRetry, setAllowRetry] = useState(true);
+ const [dueDate, setDueDate] = useState("");
+ const [importantDateType, setImportantDateType] = useState("");
+
+ const [editingId, setEditingId] = useState(null);
+ const [editValue, setEditValue] = useState("");
useEffect(() => {
const loadData = async () => {
@@ -39,6 +59,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setSelectedCategoryId(lessonData.grading_category_id || "");
setMaxAttempts(lessonData.max_attempts);
setAllowRetry(lessonData.allow_retry);
+ setDueDate(lessonData.due_date ? new Date(lessonData.due_date).toISOString().split('T')[0] : "");
+ setImportantDateType(lessonData.important_date_type || "");
if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks);
@@ -64,6 +86,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
loadData();
}, [params.id, params.lessonId]);
+ const handleSaveLessonTitle = async () => {
+ if (!lesson || !editValue) return;
+ try {
+ const updated = await cmsApi.updateLesson(lesson.id, { title: editValue });
+ setLesson(updated);
+ setEditingId(null);
+ } catch {
+ alert("Failed to update title");
+ }
+ };
+
const handleSave = async () => {
if (!lesson) return;
setIsSaving(true);
@@ -74,7 +107,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
is_graded: isGraded,
grading_category_id: selectedCategoryId || null,
max_attempts: maxAttempts,
- allow_retry: allowRetry
+ allow_retry: allowRetry,
+ due_date: dueDate ? new Date(dueDate).toISOString() : undefined,
+ important_date_type: (importantDateType || undefined) as any
});
setLesson(updated);
setEditMode(false);
@@ -117,6 +152,19 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setBlocks(newBlocks);
};
+ const handleTranscribe = async () => {
+ if (!lesson) return;
+ setIsTranscribing(true);
+ try {
+ const updated = await cmsApi.transcribeLesson(lesson.id);
+ setLesson(updated);
+ } catch {
+ alert("Failed to transcribe video.");
+ } finally {
+ setIsTranscribing(false);
+ }
+ };
+
const handleSummarize = async () => {
if (!lesson) return;
setIsGeneratingSummary(true);
@@ -155,7 +203,31 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
/
Activity
-
{lesson.title}
+
+ {editingId === 'lesson-title' ? (
+
+ setEditValue(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSaveLessonTitle()}
+ className="text-4xl font-black bg-transparent border-b-2 border-blue-500 focus:outline-none"
+ />
+
+
+
+ ) : (
+
+
{lesson.title}
+
+
+ )}
+
@@ -259,7 +331,98 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
)}
- {/* AI Summary Section */}
+ {editMode && (
+
+
+
+ 📅 Scheduling & Deadlines
+
+
Set deadlines and mark important dates for this activity
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* AI Magic Section */}
+ {editMode && (
+
+
+
🪄
+
+
AI Content Assistant
+
Automate your content creation
+
+
+
+
+ {(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* AI Summary Visualization */}
{(summary || editMode) && (
@@ -270,15 +433,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
Key insights generated by intelligence
- {editMode && (
-
- )}
{editMode ? (
@@ -300,32 +454,33 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{blocks.map((block, index) => (
{editMode && (
-
+
+
Move
-
+
-
+
)}
diff --git a/web/studio/src/app/courses/[id]/page.tsx b/web/studio/src/app/courses/[id]/page.tsx
index 6f59cb3..992c638 100644
--- a/web/studio/src/app/courses/[id]/page.tsx
+++ b/web/studio/src/app/courses/[id]/page.tsx
@@ -3,6 +3,23 @@
import { useEffect, useState } from "react";
import { cmsApi, Course, Module, Lesson } from "@/lib/api";
import Link from "next/link";
+import {
+ Plus,
+ Pencil,
+ ChevronUp,
+ ChevronDown,
+ PlayCircle,
+ FileText,
+ Calendar,
+ CheckCircle2,
+ Settings,
+ BarChart2,
+ Layout,
+ Save,
+ X,
+ GripVertical,
+ Trash2
+} from "lucide-react";
interface FullModule extends Module {
lessons: Lesson[];
@@ -13,14 +30,19 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
const [modules, setModules] = useState
([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editValue, setEditValue] = useState("");
+
+ const startEditing = (id: string, currentTitle: string) => {
+ setEditingId(id);
+ setEditValue(currentTitle);
+ };
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
- // Use cmsApi for consistent, typed data fetching
const data = await cmsApi.getCourseWithFullOutline(params.id);
-
setCourse(data);
setModules(data.modules as FullModule[]);
} catch (err) {
@@ -35,34 +57,120 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
}, [params.id]);
const handleAddModule = async () => {
- const title = prompt("Module Title:");
- if (!title) return;
-
+ const title = "New Module";
try {
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
- setModules([...modules, { ...newMod, lessons: [] }]);
+ const fullMod = { ...newMod, lessons: [] };
+ setModules([...modules, fullMod]);
+ setEditingId(newMod.id);
+ setEditValue(title);
} catch {
alert("Failed to create module");
}
};
const handleAddLesson = async (moduleId: string) => {
- const title = prompt("Lesson Title:");
- if (!title) return;
+ const mod = modules.find(m => m.id === moduleId);
+ if (!mod) return;
+ const title = "New Lesson";
try {
- // Default to 'video' for now as a content type
- const newLesson = await cmsApi.createLesson(moduleId, title, "video", 1);
- setModules(modules.map(mod =>
- mod.id === moduleId
- ? { ...mod, lessons: [...mod.lessons, newLesson] }
- : mod
+ const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1);
+ setModules(modules.map(m =>
+ m.id === moduleId
+ ? { ...m, lessons: [...m.lessons, newLesson] }
+ : m
));
+ setEditingId(newLesson.id);
+ setEditValue(title);
} catch {
alert("Failed to create lesson");
}
};
+ const handleSaveTitle = async (id: string, type: 'module' | 'lesson') => {
+ if (!editValue) {
+ setEditingId(null);
+ return;
+ }
+ try {
+ if (type === 'module') {
+ await cmsApi.updateModule(id, { title: editValue });
+ setModules(modules.map(m => m.id === id ? { ...m, title: editValue } : m));
+ } else {
+ await cmsApi.updateLesson(id, { title: editValue });
+ setModules(modules.map(mod => ({
+ ...mod,
+ lessons: mod.lessons.map(l => l.id === id ? { ...l, title: editValue } : l)
+ })));
+ }
+ setEditingId(null);
+ } catch {
+ alert("Failed to update title");
+ }
+ };
+
+ const handleDeleteModule = async (id: string) => {
+ if (!confirm("Are you sure you want to delete this module and all its lessons?")) return;
+ try {
+ await cmsApi.deleteModule(id);
+ setModules(modules.filter(m => m.id !== id));
+ } catch {
+ alert("Failed to delete module");
+ }
+ };
+
+ const handleDeleteLesson = async (moduleId: string, lessonId: string) => {
+ if (!confirm("Are you sure you want to delete this lesson?")) return;
+ try {
+ await cmsApi.deleteLesson(lessonId);
+ setModules(modules.map(m =>
+ m.id === moduleId
+ ? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) }
+ : m
+ ));
+ } catch {
+ alert("Failed to delete lesson");
+ }
+ };
+
+ const handleReorderModule = async (index: number, direction: 'up' | 'down') => {
+ const newModules = [...modules];
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
+ if (targetIndex < 0 || targetIndex >= newModules.length) return;
+
+ [newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
+
+ const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 }));
+ setModules(newModules.map((m, i) => ({ ...m, position: i + 1 })));
+
+ try {
+ await cmsApi.reorderModules({ items });
+ } catch {
+ alert("Failed to save module order");
+ }
+ };
+
+ const handleReorderLesson = async (moduleId: string, lessonIndex: number, direction: 'up' | 'down') => {
+ const mod = modules.find(m => m.id === moduleId);
+ if (!mod) return;
+
+ const newLessons = [...mod.lessons];
+ const targetIndex = direction === 'up' ? lessonIndex - 1 : lessonIndex + 1;
+ if (targetIndex < 0 || targetIndex >= newLessons.length) return;
+
+ [newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
+
+ const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 }));
+ setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m));
+
+ try {
+ await cmsApi.reorderLessons({ items });
+ } catch {
+ alert("Failed to save lesson order");
+ }
+ };
+
const [isPublishing, setIsPublishing] = useState(false);
const handlePublish = async () => {
@@ -73,7 +181,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
alert("Course published successfully to LMS!");
} catch (err) {
console.error("Publish failed:", err);
- alert("Failed to publish course. Check if LMS service is reachable.");
+ alert("Failed to publish course.");
} finally {
setIsPublishing(false);
}
@@ -84,76 +192,206 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
return (
- {/* ... navigation ... */}
- Courses
+ Courses
/
{course?.title}
-
+
{course?.title}
-
Editor - Outline (ID: {params.id})
+
+ Editor - Outline
+
+ {course?.pacing_mode?.replace('_', ' ') || 'Self Paced'}
+
+
-
+
- Outline
- Grading
- Analytics
- Settings
-
+
+ Outline
+
+
+ Grading
+
+
+ Calendar
+
+
+ Analytics
+
+
+ Settings
+
-
- {modules.map((module) => (
-
-
- Module {module.position}: {module.title}
-
-
-
- {module.lessons.map(lesson => (
-
-
-
-
- {lesson.content_type === 'video' ? '🎬' : '📄'}
-
- {lesson.title}
-
-
- {lesson.transcription && CC}
- {lesson.content_type}
-
+
+ {modules.map((module, mIndex) => (
+
+
+
+
+
+
+
+
+
+ {editingId === module.id ? (
+
+ setEditValue(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(module.id, 'module')}
+ className="bg-black/40 border border-blue-500/50 rounded px-3 py-1 flex-1 text-white focus:outline-none"
+ />
+
+
-
+ ) : (
+
+
{ setEditingId(module.id); setEditValue(module.title); }}
+ className="font-semibold text-lg text-blue-400 cursor-pointer hover:text-blue-300 transition-colors"
+ >
+ Module {module.position}: {module.title}
+
+
+
+ )}
+
+
+
+
+
+
+ {module.lessons.map((lesson, lIndex) => (
+
+
+
+
+
+
+
+ {editingId === lesson.id ? (
+
+ setEditValue(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(lesson.id, 'lesson')}
+ className="bg-transparent border-none flex-1 text-white focus:outline-none"
+ />
+
+
+
+ ) : (
+
+
+
+ {lesson.content_type === 'video' ?
:
}
+
+
+
{ e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
+ className="font-medium hover:text-blue-400 transition-colors"
+ >
+ {lesson.title}
+
+
+
{lesson.content_type}
+ {lesson.due_date && (
+
+
+ {new Date(lesson.due_date).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+
))}
@@ -161,9 +399,9 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
diff --git a/web/studio/src/app/courses/[id]/settings/page.tsx b/web/studio/src/app/courses/[id]/settings/page.tsx
index 5da9994..e5fb281 100644
--- a/web/studio/src/app/courses/[id]/settings/page.tsx
+++ b/web/studio/src/app/courses/[id]/settings/page.tsx
@@ -2,8 +2,9 @@
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
+import Link from "next/link";
import { cmsApi, Course } from "@/lib/api";
-import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen } from "lucide-react";
+import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Layout, CheckCircle2 } from "lucide-react";
const DEFAULT_CERTIFICATE_TEMPLATE = `
@@ -28,6 +29,9 @@ export default function CourseSettingsPage() {
const [certificateTemplate, setCertificateTemplate] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
+ const [pacingMode, setPacingMode] = useState<'self_paced' | 'instructor_led'>("self_paced");
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
useEffect(() => {
const fetchCourse = async () => {
@@ -36,6 +40,9 @@ export default function CourseSettingsPage() {
setCourse(data);
setPassingPercentage(data.passing_percentage || 70);
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
+ setPacingMode(data.pacing_mode || "self_paced");
+ setStartDate(data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : "");
+ setEndDate(data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : "");
} catch (err) {
console.error("Failed to load course", err);
} finally {
@@ -50,7 +57,10 @@ export default function CourseSettingsPage() {
try {
const updated = await cmsApi.updateCourse(id, {
passing_percentage: passingPercentage,
- certificate_template: certificateTemplate
+ certificate_template: certificateTemplate,
+ pacing_mode: pacingMode,
+ start_date: startDate ? new Date(startDate).toISOString() : undefined,
+ end_date: endDate ? new Date(endDate).toISOString() : undefined
});
setCourse(updated);
alert("Course settings updated successfully!");
@@ -97,6 +107,23 @@ export default function CourseSettingsPage() {
+
+
+
+ Outline
+
+
+ Grading
+
+
+ Calendar
+
+
+ Settings
+
+
+
+
{/* Passing Percentage Section */}
@@ -163,6 +190,70 @@ export default function CourseSettingsPage() {
+ {/* Course Pacing Section */}
+
+
+
+
+
+
Course Pacing & Schedule
+
+
+
+
+
+
+
+
+
+
+
+ {pacingMode === 'instructor_led' && (
+
+
+
+
+
+
+
+ setStartDate(e.target.value)}
+ className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
+ />
+
+
+
+
+
+
+ setEndDate(e.target.value)}
+ className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
+ />
+
+
+
+
+ )}
+
+
+
{/* Certificate Template Section */}
diff --git a/web/studio/src/components/blocks/DescriptionBlock.tsx b/web/studio/src/components/blocks/DescriptionBlock.tsx
index 29307b3..5f1c80b 100644
--- a/web/studio/src/components/blocks/DescriptionBlock.tsx
+++ b/web/studio/src/components/blocks/DescriptionBlock.tsx
@@ -18,7 +18,7 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
{editMode ? (
-
+
{editMode ? (
-
+
{editMode ? (
-
+
!submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
- matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
- "border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
+ matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
+ "border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`}
>
{pair.left}
diff --git a/web/studio/src/components/blocks/MediaBlock.tsx b/web/studio/src/components/blocks/MediaBlock.tsx
index 846c44c..17b76c8 100644
--- a/web/studio/src/components/blocks/MediaBlock.tsx
+++ b/web/studio/src/components/blocks/MediaBlock.tsx
@@ -45,7 +45,7 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
{editMode ? (
-
+
{editMode ? (
-
+
handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
- "border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
+ "border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`}
>
{item.value}
@@ -151,8 +151,8 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
key={i}
onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
- isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
- "border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
+ isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
+ "border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`}
>
{i + 1}.
diff --git a/web/studio/src/components/blocks/QuizBlock.tsx b/web/studio/src/components/blocks/QuizBlock.tsx
index 31019b1..873e7f5 100644
--- a/web/studio/src/components/blocks/QuizBlock.tsx
+++ b/web/studio/src/components/blocks/QuizBlock.tsx
@@ -76,7 +76,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
{editMode ? (
-
+
{editMode ? (
-
+
setUserAnswer(e.target.value)}
disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
- ? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
- : "border-white/10 focus:border-blue-500 text-white"
+ ? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
+ : "border-white/10 focus:border-blue-500 text-white"
}`}
placeholder="Type your answer..."
/>
diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts
index 9738fc5..4c67488 100644
--- a/web/studio/src/lib/api.ts
+++ b/web/studio/src/lib/api.ts
@@ -5,6 +5,9 @@ export interface Course {
title: string;
description?: string;
instructor_id: string;
+ pacing_mode: 'self_paced' | 'instructor_led';
+ start_date?: string;
+ end_date?: string;
passing_percentage: number;
certificate_template?: string;
created_at: string;
@@ -57,26 +60,40 @@ export interface Lesson {
grading_category_id: string | null;
max_attempts: number | null;
allow_retry: boolean;
+ due_date?: string;
+ important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
summary?: string;
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
+ created_at: string;
}
export interface Organization {
id: string;
name: string;
+ domain?: string;
+ logo_url?: string;
+ primary_color?: string;
+ secondary_color?: string;
+ certificate_template?: string;
created_at: string;
updated_at: string;
}
+export interface BrandingPayload {
+ primary_color?: string;
+ secondary_color?: string;
+}
+
export interface User {
id: string;
email: string;
full_name: string;
role: string;
+ organization_id?: string;
}
export interface AuthResponse {
@@ -152,6 +169,8 @@ const apiFetch = (url: string, options: RequestInit = {}) => {
export const cmsApi = {
// Organization
getOrganization: (): Promise
=> apiFetch('/organization'),
+ getOrganizations: (): Promise => apiFetch('/organizations'),
+ createOrganization: (name: string, domain?: string): Promise => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
// Auth
register: (payload: AuthPayload): Promise => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
@@ -171,8 +190,13 @@ export const cmsApi = {
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
getLesson: (id: string): Promise => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial): Promise => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
+ transcribeLesson: (id: string): Promise => apiFetch(`/lessons/${id}/transcribe`, { method: 'POST' }),
summarizeLesson: (id: string): Promise => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string): Promise => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
+ deleteModule: (id: string): Promise => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
+ deleteLesson: (id: string): Promise => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
+ reorderModules: (payload: { items: { id: string, position: number }[] }): Promise => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
+ reorderLessons: (payload: { items: { id: string, position: number }[] }): Promise => apiFetch('/lessons/reorder', { method: 'POST', body: JSON.stringify(payload) }),
// Grading
getGradingCategories: (courseId: string): Promise => apiFetch(`/courses/${courseId}/grading`),
@@ -183,6 +207,10 @@ export const cmsApi = {
getAuditLogs: (): Promise => apiFetch('/audit-logs'),
getCourseAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics`),
+ // Users
+ getAllUsers: (): Promise => apiFetch('/users'),
+ updateUser: (id: string, role: string, organization_id: string): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }),
+
// Assets
uploadAsset: (file: File): Promise => {
const formData = new FormData();
@@ -204,4 +232,23 @@ export const cmsApi = {
return res.json();
});
},
+ // Organizations Branding
+ getOrganizationBranding: (id: string): Promise => apiFetch(`/organizations/${id}/branding`),
+ updateOrganizationBranding: (id: string, payload: BrandingPayload): Promise => apiFetch(`/organizations/${id}/branding`, { method: 'PUT', body: JSON.stringify(payload) }),
+ uploadOrganizationLogo: (id: string, file: File): Promise => {
+ const formData = new FormData();
+ formData.append('file', file);
+ const token = getToken();
+ const headers: Record = {
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {})
+ };
+ return fetch(`${API_BASE_URL}/organizations/${id}/logo`, {
+ method: 'POST',
+ headers,
+ body: formData,
+ }).then(res => {
+ if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Logo upload failed')));
+ return res.json();
+ });
+ },
};
\ No newline at end of file