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}

+
+ +
+
+
Exam +
+
+
Assignment +
+
+
Task +
+
+
+ +
+
+
+
+

{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 ( +
+ +
+ {branding?.logo_url ? ( + {branding.name} + ) : ( +
L
+ )} +
+
+ + {branding?.name?.toUpperCase() || 'LEARN'} + + {!branding && EXPERIENCE} +
+ + + +
+ ); +} + export default function RootLayout({ children, }: Readonly<{ @@ -19,34 +50,19 @@ export default function RootLayout({ return ( - - {/* Header */} -
- -
- L -
- LEARNEXPERIENCE - - - -
- -
- {children} -
- - {/* Footer */} -
-

- Powered by OpenCCB © 2023. Advanced Agentic Coding. -

-
-
+ + + +
+ {children} +
+
+

+ Powered by OpenCCB © 2023. Advanced Agentic Coding. +

+
+
+
); 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} + ) : ( + + )}

{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 ? ( + Preview + ) : ( + + )} +
+
+ +

PNG, JPG or SVG. Max 2MB.

+
+
+
+ + {/* Colors */} +
+
+ +
+ setPrimaryColor(e.target.value)} + className="w-10 h-10 rounded cursor-pointer bg-transparent border-none" + /> + setPrimaryColor(e.target.value)} + className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono" + /> +
+
+
+ +
+ setSecondaryColor(e.target.value)} + className="w-10 h-10 rounded cursor-pointer bg-transparent border-none" + /> + setSecondaryColor(e.target.value)} + className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono" + /> +
+
+
+
+ + {/* Live Preview */} +
+ +
+ {/* Mock Experience Header */} +
+
+
+ {selectedOrg.logo_url ? ( + + ) :
} +
+
+
+
+
+
+
+
+ {/* Mock Experience Content */} +
+
+
+
+
+
+
+
+
+ GET STARTED +
+
+
+
+
+
+

+ 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