From 663950aa0eff817f50c8d6acb8c2220686037910 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 15 Jan 2026 18:02:04 -0300 Subject: [PATCH] feat: Introduce multi-tenancy support with organization-specific data, add interactive transcript functionality, and enhance lesson/course schemas. --- docker-compose.yml | 17 +- install.sh | 127 ++++++--- ...0260115000002_make_audit_user_nullable.sql | 101 +++++++ ...0260115000003_add_transcription_status.sql | 5 + services/cms-service/src/handlers.rs | 146 ++++++---- services/cms-service/src/handlers_branding.rs | 2 + .../cms-service/src/handlers_update_module.rs | 13 +- services/cms-service/src/main.rs | 41 ++- .../20260115000001_add_org_to_all_tables.sql | 5 - ...260115000004_add_organization_branding.sql | 9 + .../20260115000005_fix_org_table_for_reg.sql | 11 + ...20260115000006_add_updated_at_to_users.sql | 4 + ...60115000007_add_pacing_mode_to_courses.sql | 4 + ...60115000008_fix_enrollment_constraints.sql | 9 + .../20260115000009_sync_lesson_columns.sql | 7 + services/lms-service/src/handlers.rs | 90 +++++-- shared/common/src/models.rs | 5 + .../courses/[id]/lessons/[lessonId]/page.tsx | 254 ++++++++++-------- web/experience/src/app/page.tsx | 14 +- .../src/components/InteractiveTranscript.tsx | 95 +++++++ .../src/components/blocks/MediaPlayer.tsx | 57 ++-- web/experience/src/lib/api.ts | 3 +- .../courses/[id]/lessons/[lessonId]/page.tsx | 91 ++++++- web/studio/src/components/FileUpload.tsx | 70 +++-- .../src/components/blocks/MediaBlock.tsx | 3 +- web/studio/src/lib/api.ts | 52 ++-- 26 files changed, 933 insertions(+), 302 deletions(-) create mode 100644 services/cms-service/migrations/20260115000002_make_audit_user_nullable.sql create mode 100644 services/cms-service/migrations/20260115000003_add_transcription_status.sql create mode 100644 services/lms-service/migrations/20260115000004_add_organization_branding.sql create mode 100644 services/lms-service/migrations/20260115000005_fix_org_table_for_reg.sql create mode 100644 services/lms-service/migrations/20260115000006_add_updated_at_to_users.sql create mode 100644 services/lms-service/migrations/20260115000007_add_pacing_mode_to_courses.sql create mode 100644 services/lms-service/migrations/20260115000008_fix_enrollment_constraints.sql create mode 100644 services/lms-service/migrations/20260115000009_sync_lesson_columns.sql create mode 100644 web/experience/src/components/InteractiveTranscript.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 7b05d1d..0aa5727 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,9 +21,12 @@ services: DATABASE_URL: postgresql://user:password@db:5432/openccb_cms JWT_SECRET: openccb_secret_key_2025_production NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 + LMS_INTERNAL_URL: http://experience:3002 volumes: - uploads_data:/app/uploads env_file: .env + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - db @@ -51,13 +54,13 @@ services: environment: - DEVICE=${WHISPER_DEVICE:-cpu} # GPU support for RTX 2070 Super - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [ gpu ] + #deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [ gpu ] e2e: build: diff --git a/install.sh b/install.sh index 62a49e0..ff9fcb6 100755 --- a/install.sh +++ b/install.sh @@ -113,29 +113,38 @@ if [ "$HAS_NVIDIA" = true ]; then update_env "WHISPER_DEVICE" "cuda" update_env "LOCAL_LLM_MODEL" "llama3:8b" # Uncomment GPU deploy section in docker-compose.yml while preserving indentation - sed -i '/deploy:/s/# //' docker-compose.yml - sed -i '/resources:/s/# //' docker-compose.yml - sed -i '/reservations:/s/# //' docker-compose.yml - sed -i '/devices:/s/# //' docker-compose.yml - sed -i '/- driver: nvidia/s/# //' docker-compose.yml - sed -i '/count: 1/s/# //' docker-compose.yml - sed -i '/capabilities: \[ gpu \]/s/# //' docker-compose.yml + sed -i 's/^ #deploy:/ deploy:/' docker-compose.yml + sed -i 's/^ # resources:/ resources:/' docker-compose.yml + sed -i 's/^ # reservations:/ reservations:/' docker-compose.yml + sed -i 's/^ # devices:/ devices:/' docker-compose.yml + sed -i 's/^ # - driver: nvidia/ - driver: nvidia/' docker-compose.yml + sed -i 's/^ # count: 1/ count: 1/' docker-compose.yml + sed -i 's/^ # capabilities: \[ gpu \]/ capabilities: [ gpu ]/' docker-compose.yml else update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu" update_env "WHISPER_DEVICE" "cpu" update_env "LOCAL_LLM_MODEL" "phi3:mini" - # Ensure it's commented (if it was previously uncommented) - # (Simple approach: we leave it as is or explicitly comment it out) + # Comment GPU deploy section in docker-compose.yml + sed -i 's/^ deploy:/ #deploy:/' docker-compose.yml + sed -i 's/^ resources:/ # resources:/' docker-compose.yml + sed -i 's/^ reservations:/ # reservations:/' docker-compose.yml + sed -i 's/^ devices:/ # devices:/' docker-compose.yml + sed -i 's/^ - driver: nvidia/ # - driver: nvidia/' docker-compose.yml + sed -i 's/^ count: 1/ # count: 1/' docker-compose.yml + sed -i 's/^ capabilities: \[ gpu \]/ # capabilities: [ gpu ]/' docker-compose.yml fi # Ask for DB credentials if not set if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then read -p "Enter Database Password [password]: " DB_PASS DB_PASS=${DB_PASS:-password} - update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb" - update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms" - update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms" + update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb?sslmode=disable" + update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms?sslmode=disable" + update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms?sslmode=disable" update_env "JWT_SECRET" "supersecretsecret" + update_env "AI_PROVIDER" "local" + update_env "LOCAL_WHISPER_URL" "http://whisper:8000" + update_env "LOCAL_OLLAMA_URL" "http://host.docker.internal:11434" update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" fi @@ -161,12 +170,40 @@ fi # 6. Database Initialization (Integrated db-mgmt.sh) echo "" +read -p "Do you want a CLEAN installation? (This will DELETE all existing data) [y/N]: " CLEAN_INSTALL +if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then + echo "🐘 Resetting database for a clean installation..." + docker compose down -v || true +fi + echo "🐘 Starting database with Docker..." docker compose up -d db -sleep 5 # Simple wait -CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2) -LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2) +echo "⏳ Waiting for database to be ready..." +RETRIES=30 +until docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do + echo -n "." + sleep 1 + RETRIES=$((RETRIES-1)) +done +echo "" + +# Reset retries for the second check and ensure we can actually execute queries +RETRIES=30 +until docker exec openccb-db-1 psql -U user -d openccb -c "SELECT 1" &> /dev/null || [ $RETRIES -eq 0 ]; do + echo -n "+" + sleep 1 + RETRIES=$((RETRIES-1)) +done +echo "" + +if [ $RETRIES -eq 0 ]; then + echo "❌ Database failed to start in time." + exit 1 +fi + +CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2-) +LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2-) echo "🏗️ Creating databases and running migrations..." DATABASE_URL=$CMS_URL sqlx database create || true @@ -176,35 +213,49 @@ DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations # 7. System Initialization (Integrated init-system.sh) echo "" -echo "👤 Creating Initial Administrator..." -API_URL="http://localhost:3001" -# Start the Studio service (which contains CMS) to allow admin creation -echo "🚀 Starting services to allow admin creation..." -docker compose up -d --build -echo "⏳ Waiting for CMS API to be ready..." -START_WAIT=$SECONDS -until curl -s "$API_URL/auth/login" &> /dev/null || [ $((SECONDS - START_WAIT)) -gt 60 ]; do sleep 2; done +echo "🔍 Checking for existing administrator..." +ADMIN_EXISTS=$(docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin');" | xargs 2>/dev/null || echo "f") + +if [ "$ADMIN_EXISTS" != "t" ]; then + echo "👤 Configure Initial Administrator" + read -p "Full Name [System Admin]: " ADMIN_NAME + ADMIN_NAME=${ADMIN_NAME:-System Admin} + read -p "Admin Email [admin@example.com]: " ADMIN_EMAIL + ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} + read -s -p "Admin Password [password123]: " ADMIN_PASS + ADMIN_PASS=${ADMIN_PASS:-password123} + echo "" + read -p "Organization Name [Default Organization]: " ORG_NAME + ORG_NAME=${ORG_NAME:-Default Organization} +fi -read -p "Admin Email [admin@example.com]: " ADMIN_EMAIL -ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} -read -s -p "Admin Password [password123]: " ADMIN_PASS -ADMIN_PASS=${ADMIN_PASS:-password123} echo "" +echo "🚀 Starting all services..." +docker compose up -d --build -PAYLOAD=$(jq -n \ - --arg email "$ADMIN_EMAIL" \ - --arg password "$ADMIN_PASS" \ - --arg full_name "System Admin" \ - --arg org_name "Default Organization" \ - --arg role "admin" \ - '{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}') +if [ "$ADMIN_EXISTS" != "t" ]; then + echo "⏳ Waiting for CMS API to be ready..." + API_URL="http://localhost:3001" + START_WAIT=$SECONDS + until curl -s "$API_URL/auth/login" &> /dev/null || [ $((SECONDS - START_WAIT)) -gt 60 ]; do sleep 2; done -RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD") + PAYLOAD=$(jq -n \ + --arg email "$ADMIN_EMAIL" \ + --arg password "$ADMIN_PASS" \ + --arg full_name "$ADMIN_NAME" \ + --arg org_name "$ORG_NAME" \ + --arg role "admin" \ + '{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}') -if echo "$RESPONSE" | grep -q "token"; then - echo "✅ Success! Administrator created." + RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD") + + if echo "$RESPONSE" | grep -q "token"; then + echo "✅ Success! Administrator created." + else + echo "⚠️ Failed to create administrator." + fi else - echo "⚠️ Failed to create administrator (it might already exist)." + echo "✅ Administrator already exists. Skipping registration." fi echo "" diff --git a/services/cms-service/migrations/20260115000002_make_audit_user_nullable.sql b/services/cms-service/migrations/20260115000002_make_audit_user_nullable.sql new file mode 100644 index 0000000..ee192cf --- /dev/null +++ b/services/cms-service/migrations/20260115000002_make_audit_user_nullable.sql @@ -0,0 +1,101 @@ +-- Migration: Make user_id nullable in audit_logs +-- This allows logging system actions and registrations where no session user exists yet. + +ALTER TABLE audit_logs ALTER COLUMN user_id DROP NOT NULL; + +-- Also update the trigger to use the new user's ID as the actor for registrations if no session user is set +CREATE OR REPLACE FUNCTION fn_trigger_audit_log() +RETURNS TRIGGER AS $$ +DECLARE + v_user_id UUID; + v_org_id UUID; + v_ip INET; + v_user_agent TEXT; + v_event_type VARCHAR(50); + v_old_data JSONB := NULL; + v_new_data JSONB := NULL; + v_action VARCHAR(50); +BEGIN + -- Try to get context from session variables + BEGIN + v_user_id := current_setting('app.current_user_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_user_id := NULL; + END; + + BEGIN + v_org_id := current_setting('app.current_org_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_org_id := NULL; + END; + + BEGIN + v_ip := current_setting('app.client_ip', true)::INET; + EXCEPTION WHEN OTHERS THEN + v_ip := NULL; + END; + + BEGIN + v_user_agent := current_setting('app.user_agent', true); + EXCEPTION WHEN OTHERS THEN + v_user_agent := NULL; + END; + + BEGIN + v_event_type := current_setting('app.event_type', true); + EXCEPTION WHEN OTHERS THEN + v_event_type := 'USER_EVENT'; + END; + + -- Handle different operations + IF (TG_OP = 'DELETE') THEN + v_old_data := to_jsonb(OLD); + v_action := 'DELETE'; + ELSIF (TG_OP = 'UPDATE') THEN + v_old_data := to_jsonb(OLD); + v_new_data := to_jsonb(NEW); + v_action := 'UPDATE'; + ELSIF (TG_OP = 'INSERT') THEN + v_new_data := to_jsonb(NEW); + v_action := 'INSERT'; + + -- Special case: For user registration, use the new ID if no session user is set + IF TG_TABLE_NAME = 'users' AND v_user_id IS NULL THEN + v_user_id := NEW.id; + END IF; + END IF; + + -- Insert into audit_logs + INSERT INTO audit_logs ( + organization_id, + user_id, + action, + entity_type, + entity_id, + event_type, + old_data, + new_data, + ip_address, + user_agent, + changes + ) + VALUES ( + COALESCE(v_org_id, (CASE WHEN TG_OP = 'DELETE' THEN OLD.organization_id ELSE NEW.organization_id END)), + v_user_id, + v_action, + TG_TABLE_NAME, + CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END, + COALESCE(v_event_type, 'USER_EVENT'), + v_old_data, + v_new_data, + v_ip, + v_user_agent, + COALESCE(v_new_data, v_old_data) + ); + + IF (TG_OP = 'DELETE') THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/migrations/20260115000003_add_transcription_status.sql b/services/cms-service/migrations/20260115000003_add_transcription_status.sql new file mode 100644 index 0000000..c27706f --- /dev/null +++ b/services/cms-service/migrations/20260115000003_add_transcription_status.sql @@ -0,0 +1,5 @@ +-- Add transcription_status to lessons table +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS transcription_status VARCHAR(20) DEFAULT 'idle'; + +-- Optional: Update existing lessons with transcriptions to 'completed' +UPDATE lessons SET transcription_status = 'completed' WHERE transcription IS NOT NULL AND transcription != '{}'::jsonb; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 0ada178..46b467a 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -51,7 +51,16 @@ pub async fn publish_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 4. Fetch Lessons for each Module + // 4. Fetch Organization + let organization = sqlx::query_as::<_, common::models::Organization>( + "SELECT * FROM organizations WHERE id = $1", + ) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 5. Fetch Lessons for each Module for module in modules { let lessons = sqlx::query_as::<_, Lesson>( "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", @@ -66,15 +75,16 @@ pub async fn publish_course( let payload = PublishedCourse { course, + organization, grading_categories, modules: pub_modules, }; // 4. Send to LMS - // Using service name for Docker compatibility + let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let client = reqwest::Client::new(); let res = client - .post("http://lms-service:3002/ingest") + .post(format!("{}/ingest", lms_url)) .json(&payload) .send() .await @@ -105,7 +115,7 @@ pub async fn publish_course( ) .await; - Ok(StatusCode::OK) + Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] @@ -489,6 +499,7 @@ pub async fn process_transcription( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { + tracing::info!("Received transcription request for lesson: {}", id); // 1. Fetch lesson let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) @@ -508,13 +519,63 @@ pub async fn process_transcription( 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); + // 2. Read file to verify it exists + if !tokio::fs::metadata(&file_path).await.is_ok() { + tracing::error!("File not found: {}", file_path); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + // 3. Set status to queued + let updated_lesson = sqlx::query_as::<_, Lesson>( + "UPDATE lessons SET transcription_status = 'queued' WHERE id = $1 RETURNING *", + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Database update failed (queued): {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - // 3. Configuration + log_action( + &pool, + org_ctx.id, + claims.sub, + "TRANSCRIPTION_QUEUED", + "Lesson", + id, + json!({ "status": "queued" }), + ) + .await; + + Ok(Json(updated_lesson)) +} + +pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), String> { + // 1. Fetch lesson + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|e| format!("Lesson fetch failed: {}", e))?; + + let url = lesson.content_url.ok_or("No content URL")?; + let filename = url.trim_start_matches("/assets/"); + let file_path = format!("uploads/{}", filename); + + // 2. Set status to processing + sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1") + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Update to processing failed: {}", e))?; + + // 3. Read file + let file_data = tokio::fs::read(&file_path) + .await + .map_err(|e| format!("File read failed ({}): {}", file_path, e))?; + + // 4. Configuration let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); @@ -527,7 +588,7 @@ pub async fn process_transcription( "medium".to_string(), ) } else { - let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?; ( "https://api.openai.com/v1/audio/transcriptions".to_string(), format!("Bearer {}", api_key), @@ -538,7 +599,7 @@ pub async fn process_transcription( let part = reqwest::multipart::Part::bytes(file_data) .file_name(filename.to_string()) .mime_str("application/octet-stream") - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| format!("Multipart part creation failed: {}", e))?; let form = reqwest::multipart::Form::new() .part("file", part) @@ -550,21 +611,20 @@ pub async fn process_transcription( request = request.header("Authorization", auth_header); } - let response = request.send().await.map_err(|e| { - tracing::error!("Transcription request failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let response = request + .send() + .await + .map_err(|e| format!("Transcription request failed: {}", e))?; 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); + return Err(format!("Transcription API error: {}", err_body)); } - let whisper_data: serde_json::Value = response.json().await.map_err(|e| { - tracing::error!("Whisper JSON parse failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let whisper_data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Whisper JSON parse failed: {}", e))?; // Extract text and segments (cues) let text = whisper_data["text"].as_str().unwrap_or_default(); @@ -583,35 +643,19 @@ pub async fn process_transcription( let transcription = json!({ "en": text, - "es": "", // Could add a translation step here + "es": "", "cues": cues }); - // 4. Update lesson - let updated_lesson = sqlx::query_as::<_, Lesson>( - "UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *", - ) - .bind(transcription) - .bind(id) - .fetch_one(&pool) - .await - .map_err(|e| { - tracing::error!("Database update failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + // 5. Update lesson + sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2") + .bind(transcription) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Final database update failed: {}", e))?; - log_action( - &pool, - org_ctx.id, - claims.sub, - "TRANSCRIPTION_PROCESSED", - "Lesson", - id, - json!({}), - ) - .await; - - Ok(Json(updated_lesson)) + Ok(()) } pub async fn summarize_lesson( @@ -620,6 +664,7 @@ pub async fn summarize_lesson( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { + tracing::info!("Received summarization request for lesson: {}", id); // 1. Fetch lesson let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) @@ -731,6 +776,7 @@ pub async fn generate_quiz( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { + tracing::info!("Received quiz generation request for lesson: {}", id); // 1. Fetch lesson let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(id) @@ -1229,6 +1275,7 @@ pub async fn upload_asset( State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { + tracing::info!("Starting upload_asset for org: {}", org_ctx.id); let mut filename = String::new(); let mut data = Vec::new(); let mut mimetype = String::new(); @@ -1302,6 +1349,7 @@ pub async fn upload_asset( let url = format!("/assets/{}", storage_filename); + tracing::info!("Upload successful: {} -> {}", filename, url); Ok(Json(UploadResponse { id: asset_id, filename, @@ -1451,8 +1499,9 @@ pub async fn get_course_analytics( // 4. Fetch from LMS let client = reqwest::Client::new(); + let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client - .get(format!("http://lms-service:3002/courses/{}/analytics", id)) + .get(format!("{}/courses/{}/analytics", lms_url, id)) .send() .await .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; @@ -1497,10 +1546,11 @@ pub async fn get_advanced_analytics( // 4. Fetch from LMS let client = reqwest::Client::new(); + let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client .get(format!( - "http://lms-service:3002/courses/{}/analytics/advanced", - id + "{}/courses/{}/analytics/advanced", + lms_url, id )) .send() .await diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs index 0d72878..c13bfab 100644 --- a/services/cms-service/src/handlers_branding.rs +++ b/services/cms-service/src/handlers_branding.rs @@ -119,6 +119,7 @@ pub async fn upload_organization_logo( log_action( &pool, + claims.org, claims.sub, "UPDATE_LOGO", "Organization", @@ -197,6 +198,7 @@ pub async fn update_organization_branding( log_action( &pool, + claims.org, claims.sub, "UPDATE_BRANDING", "Organization", diff --git a/services/cms-service/src/handlers_update_module.rs b/services/cms-service/src/handlers_update_module.rs index 313f60a..3cd3d3e 100644 --- a/services/cms-service/src/handlers_update_module.rs +++ b/services/cms-service/src/handlers_update_module.rs @@ -1,3 +1,14 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::models::Module; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::handlers::log_action; pub async fn update_module( claims: common::auth::Claims, @@ -24,7 +35,7 @@ pub async fn update_module( StatusCode::INTERNAL_SERVER_ERROR })?; - log_action(&pool, claims.sub, "UPDATE", "Module", id, json!(payload)).await; + log_action(&pool, claims.org, claims.sub, "UPDATE", "Module", id, json!(payload)).await; Ok(Json(updated_module)) } diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 461332a..6eb3838 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -6,11 +6,13 @@ mod webhooks; use axum::{ Router, middleware, routing::{delete, get, post}, + extract::DefaultBodyLimit, }; use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; use std::env; use std::net::SocketAddr; +use std::time::Duration; use tower_http::cors::{Any, CorsLayer}; #[tokio::main] @@ -25,12 +27,48 @@ async fn main() { .await .expect("Failed to connect to database"); - // Run migrations automatically sqlx::migrate!("./migrations") .run(&pool) .await .expect("Failed to run migrations"); + // Start AI Background Worker + let worker_pool = pool.clone(); + tokio::spawn(async move { + tracing::info!("AI Background Worker started"); + loop { + // Check for queued transcriptions + let queued_lessons: Vec = match sqlx::query_scalar( + "SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5" + ) + .fetch_all(&worker_pool) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::error!("Failed to fetch queued lessons: {}", e); + tokio::time::sleep(Duration::from_secs(10)).await; + continue; + } + }; + + for lesson_id in queued_lessons { + tracing::info!("Processing transcription for lesson: {}", lesson_id); + if let Err(e) = handlers::run_transcription_task(worker_pool.clone(), lesson_id).await { + tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e); + let _ = sqlx::query( + "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1" + ) + .bind(lesson_id) + .execute(&worker_pool) + .await; + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -92,6 +130,7 @@ async fn main() { .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/assets/upload", post(handlers::upload_asset)) + .layer(DefaultBodyLimit::disable()) .route( "/organizations", get(handlers::get_organizations).post(handlers::create_organization), diff --git a/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql b/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql index 534c9f3..c7c86d5 100644 --- a/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql +++ b/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql @@ -25,8 +25,3 @@ ALTER TABLE user_grades ALTER COLUMN organization_id DROP DEFAULT; ALTER TABLE user_badges ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; ALTER TABLE user_badges ADD CONSTRAINT fk_user_badge_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ALTER TABLE user_badges ALTER COLUMN organization_id DROP DEFAULT; - --- 6. Add organization_id to points_log -ALTER TABLE points_log ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; -ALTER TABLE points_log ADD CONSTRAINT fk_points_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; -ALTER TABLE points_log ALTER COLUMN organization_id DROP DEFAULT; diff --git a/services/lms-service/migrations/20260115000004_add_organization_branding.sql b/services/lms-service/migrations/20260115000004_add_organization_branding.sql new file mode 100644 index 0000000..4309716 --- /dev/null +++ b/services/lms-service/migrations/20260115000004_add_organization_branding.sql @@ -0,0 +1,9 @@ +-- Migration: Add branding columns to organizations table +-- Adds columns that exist in CMS but were missing in LMS + +ALTER TABLE organizations +ADD COLUMN domain VARCHAR(255), +ADD COLUMN logo_url TEXT, +ADD COLUMN primary_color VARCHAR(7), +ADD COLUMN secondary_color VARCHAR(7), +ADD COLUMN certificate_template TEXT; diff --git a/services/lms-service/migrations/20260115000005_fix_org_table_for_reg.sql b/services/lms-service/migrations/20260115000005_fix_org_table_for_reg.sql new file mode 100644 index 0000000..d0cccd1 --- /dev/null +++ b/services/lms-service/migrations/20260115000005_fix_org_table_for_reg.sql @@ -0,0 +1,11 @@ +-- Migration: Fix organizations table for registration +-- Adds default UUID generation and unique constraint on name + +ALTER TABLE organizations ALTER COLUMN id SET DEFAULT gen_random_uuid(); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_name_key') THEN + ALTER TABLE organizations ADD CONSTRAINT organizations_name_key UNIQUE (name); + END IF; +END $$; diff --git a/services/lms-service/migrations/20260115000006_add_updated_at_to_users.sql b/services/lms-service/migrations/20260115000006_add_updated_at_to_users.sql new file mode 100644 index 0000000..9161182 --- /dev/null +++ b/services/lms-service/migrations/20260115000006_add_updated_at_to_users.sql @@ -0,0 +1,4 @@ +-- Migration: Add updated_at to users table (LMS) +-- To match common::models::User struct requirements + +ALTER TABLE users ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/services/lms-service/migrations/20260115000007_add_pacing_mode_to_courses.sql b/services/lms-service/migrations/20260115000007_add_pacing_mode_to_courses.sql new file mode 100644 index 0000000..120b87f --- /dev/null +++ b/services/lms-service/migrations/20260115000007_add_pacing_mode_to_courses.sql @@ -0,0 +1,4 @@ +-- Migration: Add pacing_mode to courses table (LMS) +-- To match common::models::Course struct and CMS schema + +ALTER TABLE courses ADD COLUMN pacing_mode VARCHAR(50) NOT NULL DEFAULT 'self_paced'; diff --git a/services/lms-service/migrations/20260115000008_fix_enrollment_constraints.sql b/services/lms-service/migrations/20260115000008_fix_enrollment_constraints.sql new file mode 100644 index 0000000..656b816 --- /dev/null +++ b/services/lms-service/migrations/20260115000008_fix_enrollment_constraints.sql @@ -0,0 +1,9 @@ +-- Migration: Add unique constraint to enrollments table +-- Required for fn_enroll_student ON CONFLICT logic + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'enrollments_user_id_course_id_key') THEN + ALTER TABLE enrollments ADD CONSTRAINT enrollments_user_id_course_id_key UNIQUE (user_id, course_id); + END IF; +END $$; diff --git a/services/lms-service/migrations/20260115000009_sync_lesson_columns.sql b/services/lms-service/migrations/20260115000009_sync_lesson_columns.sql new file mode 100644 index 0000000..12ddfa6 --- /dev/null +++ b/services/lms-service/migrations/20260115000009_sync_lesson_columns.sql @@ -0,0 +1,7 @@ +-- Migration: Add missing columns to lessons table (LMS) +-- To match common::models::Lesson struct and CMS schema + +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS summary TEXT; +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ; +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS important_date_type VARCHAR(50); +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS transcription_status VARCHAR(20) DEFAULT 'idle'; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 4dd5321..37ad69e 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, }; use bcrypt::{DEFAULT_COST, hash, verify}; @@ -251,11 +251,40 @@ pub async fn ingest_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 1. Upsert Course + // 1. Upsert Organization let org_id = payload.course.organization_id; sqlx::query( - "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + domain = EXCLUDED.domain, + logo_url = EXCLUDED.logo_url, + primary_color = EXCLUDED.primary_color, + secondary_color = EXCLUDED.secondary_color, + certificate_template = EXCLUDED.certificate_template, + updated_at = EXCLUDED.updated_at" + ) + .bind(payload.organization.id) + .bind(&payload.organization.name) + .bind(&payload.organization.domain) + .bind(&payload.organization.logo_url) + .bind(&payload.organization.primary_color) + .bind(&payload.organization.secondary_color) + .bind(&payload.organization.certificate_template) + .bind(payload.organization.created_at) + .bind(payload.organization.updated_at) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to upsert organization during ingestion: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // 2. Upsert Course + sqlx::query( + "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -265,7 +294,8 @@ pub async fn ingest_course( passing_percentage = EXCLUDED.passing_percentage, certificate_template = EXCLUDED.certificate_template, updated_at = EXCLUDED.updated_at, - organization_id = EXCLUDED.organization_id" + organization_id = EXCLUDED.organization_id, + pacing_mode = EXCLUDED.pacing_mode" ) .bind(payload.course.id) .bind(&payload.course.title) @@ -277,6 +307,7 @@ pub async fn ingest_course( .bind(&payload.course.certificate_template) .bind(payload.course.updated_at) .bind(org_id) + .bind(&payload.course.pacing_mode) .execute(&mut *tx) .await .map_err(|e| { @@ -333,8 +364,8 @@ pub async fn ingest_course( for lesson in pub_module.lessons { sqlx::query( - "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" ) .bind(lesson.id) .bind(pub_module.module.id) @@ -350,6 +381,10 @@ pub async fn ingest_course( .bind(lesson.max_attempts) .bind(lesson.allow_retry) .bind(org_id) + .bind(&lesson.summary) + .bind(lesson.due_date) + .bind(&lesson.important_date_type) + .bind(&lesson.transcription_status) .execute(&mut *tx) .await .map_err(|e| { @@ -371,43 +406,49 @@ pub async fn get_course_outline( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { + tracing::info!("get_course_outline: fetching course {}", id); // 1. Fetch Course let course = - sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) - .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Fetch Modules let modules = sqlx::query_as::<_, Module>( - "SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position", + "SELECT * FROM modules WHERE course_id = $1 ORDER BY position", ) .bind(id) - .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 3. Fetch Grading Categories + // 3. Fetch Organization + let organization = sqlx::query_as::<_, common::models::Organization>( + "SELECT * FROM organizations WHERE id = $1", + ) + .bind(course.organization_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 4. Fetch Grading Categories let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at", ) .bind(id) - .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 4. Fetch Lessons + // 5. Fetch Lessons let mut pub_modules = Vec::new(); for module in modules { let lessons = sqlx::query_as::<_, Lesson>( - "SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position", + "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", ) .bind(module.id) - .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -417,6 +458,7 @@ pub async fn get_course_outline( Ok(Json(common::models::PublishedCourse { course, + organization, grading_categories, modules: pub_modules, })) @@ -427,10 +469,10 @@ pub async fn get_lesson_content( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { + tracing::info!("get_lesson_content: fetching lesson {}", id); let lesson = - sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") .bind(id) - .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -444,10 +486,9 @@ pub async fn get_user_enrollments( Path(user_id): Path, ) -> Result>, StatusCode> { let enrollments = sqlx::query_as::<_, Enrollment>( - "SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2", + "SELECT * FROM enrollments WHERE user_id = $1", ) .bind(user_id) - .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -491,10 +532,9 @@ pub async fn submit_lesson_score( // 1. Get lesson attempt rules let max_attempts: Option> = sqlx::query_scalar( - "SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2", + "SELECT max_attempts FROM lessons WHERE id = $1", ) .bind(payload.lesson_id) - .bind(org_ctx.id) .fetch_optional(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -560,8 +600,7 @@ pub async fn submit_lesson_score( .await; // Detect course completion logic - let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)") - .bind(org_ctx.id) + let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)") .bind(payload.course_id) .fetch_one(&pool).await.unwrap_or(0); @@ -675,11 +714,10 @@ pub async fn get_user_course_grades( Path((user_id, course_id)): Path<(Uuid, Uuid)>, ) -> Result>, StatusCode> { let grades = sqlx::query_as::<_, common::models::UserGrade>( - "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", + "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2", ) .bind(user_id) .bind(course_id) - .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index c975a4e..9bf2a9d 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -22,6 +22,7 @@ pub struct Course { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Module { pub id: Uuid, + pub organization_id: Uuid, pub course_id: Uuid, pub title: String, pub position: i32, @@ -31,6 +32,7 @@ pub struct Module { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Lesson { pub id: Uuid, + pub organization_id: Uuid, pub module_id: Uuid, pub title: String, pub content_type: String, @@ -45,12 +47,14 @@ pub struct Lesson { pub position: i32, pub due_date: Option>, pub important_date_type: Option, // "exam", "assignment", "milestone", etc. + pub transcription_status: Option, pub created_at: DateTime, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct GradingCategory { pub id: Uuid, + pub organization_id: Uuid, pub course_id: Uuid, pub name: String, pub weight: i32, // 0-100 @@ -160,6 +164,7 @@ pub struct AuthResponse { #[derive(Debug, Serialize, Deserialize)] pub struct PublishedCourse { pub course: Course, + pub organization: Organization, pub grading_categories: Vec, pub modules: Vec, } diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 3c89f98..af870f6 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -13,12 +13,16 @@ import FillInTheBlanksPlayer from "@/components/blocks/FillInTheBlanksPlayer"; import MatchingPlayer from "@/components/blocks/MatchingPlayer"; import OrderingPlayer from "@/components/blocks/OrderingPlayer"; import ShortAnswerPlayer from "@/components/blocks/ShortAnswerPlayer"; +import InteractiveTranscript from "@/components/InteractiveTranscript"; +import { ListMusic } from "lucide-react"; export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) { const [lesson, setLesson] = useState(null); const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true); + const [transcriptOpen, setTranscriptOpen] = useState(true); + const [currentTime, setCurrentTime] = useState(0); const [userGrade, setUserGrade] = useState(null); const { user } = useAuth(); @@ -44,7 +48,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } }; fetchAll(); - }, [params.id, params.lessonId]); + }, [params.id, params.lessonId, user]); if (loading) return
Loading Experience...
; if (!lesson || !course) return
Content not found.
; @@ -54,6 +58,16 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les const prevLesson = allLessons[currentIndex - 1]; const nextLesson = allLessons[currentIndex + 1]; + const hasTranscription = lesson.transcription && lesson.transcription.cues && lesson.transcription.cues.length > 0; + + const handleSeek = (time: number) => { + const videoElement = document.querySelector('video'); + if (videoElement) { + videoElement.currentTime = time; + videoElement.play(); + } + }; + return (
{/* Navigation Sidebar */} @@ -88,128 +102,152 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {/* Main Content Area */} -
-
+
+
+ {hasTranscription && ( + + )}
-
-
-
-
- {lesson.content_type === 'activity' ? 'Interactive Activity' : 'Video Lesson'} +
+
+
+
+
+ {lesson.content_type === 'activity' ? 'Interactive Activity' : 'Video Lesson'} +
+

{lesson.title}

-

{lesson.title}

-
- {lesson.summary && ( -
-

- Summary -

-

- "{lesson.summary}" -

-
- )} + {lesson.summary && ( +
+

+ Summary +

+

+ "{lesson.summary}" +

+
+ )} - {/* Render Blocks */} - {(lesson.metadata?.blocks || []).length > 0 ? ( -
- {lesson.metadata?.blocks?.map((block) => ( -
- {block.type === 'description' && ( - - )} - {block.type === 'media' && ( - - )} - {block.type === 'quiz' && ( - - )} - {block.type === 'fill-in-the-blanks' && ( - - )} - {block.type === 'matching' && ( - - )} - {block.type === 'ordering' && ( - - )} - {block.type === 'short-answer' && ( - - )} -
- ))} -
- ) : ( -
-

This lesson currently has no content.

-
- )} - - {lesson.is_graded && ( -
- {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? ( -
-
- Locked: Maximum attempts reached ({lesson.max_attempts}) + {/* Render Blocks */} + {(lesson.metadata?.blocks || []).length > 0 ? ( +
+ {lesson.metadata?.blocks?.map((block) => ( +
+ {block.type === 'description' && ( + + )} + {block.type === 'media' && ( + + )} + {block.type === 'quiz' && ( + + )} + {block.type === 'fill-in-the-blanks' && ( + + )} + {block.type === 'matching' && ( + + )} + {block.type === 'ordering' && ( + + )} + {block.type === 'short-answer' && ( + + )}
-
- Score: {userGrade.score * 100}% + ))} +
+ ) : ( +
+

This lesson currently has no content.

+
+ )} + + {lesson.is_graded && ( +
+ {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? ( +
+
+ Locked: Maximum attempts reached ({lesson.max_attempts}) +
+
+ Score: {userGrade.score * 100}% +
+

This assessment is now closed for further submissions.

-

This assessment is now closed for further submissions.

-
- ) : ( - <> - - {lesson.max_attempts && ( -

- Attempt {userGrade ? userGrade.attempts_count : 0} of {lesson.max_attempts} used -

- )} - - )} -
- )} + }} + className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn" + > + + {userGrade ? `SUBMIT ATTEMPT ${userGrade.attempts_count + 1}` : 'SUBMIT FOR GRADING'} + + + + {lesson.max_attempts && ( +

+ Attempt {userGrade ? userGrade.attempts_count : 0} of {lesson.max_attempts} used +

+ )} + + )} +
+ )} +
+ + {/* Interactive Transcript Panel */} + {hasTranscription && transcriptOpen && ( + + )}
{/* Footer Controls */} diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx index da4fd2c..4d98b5e 100644 --- a/web/experience/src/app/page.tsx +++ b/web/experience/src/app/page.tsx @@ -13,7 +13,7 @@ export default function CatalogPage() { const [enrollments, setEnrollments] = useState([]); const [loading, setLoading] = useState(true); const [gamification, setGamification] = useState<{ points: number, level: number, badges: any[] } | null>(null); - const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string }[]>([]); + const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string, courseId: string }[]>([]); const { user } = useAuth(); const router = useRouter(); @@ -21,7 +21,7 @@ export default function CatalogPage() { useEffect(() => { const fetchData = async () => { try { - const coursesData = await lmsApi.getCatalog(); + const coursesData = await lmsApi.getCatalog(user?.organization_id); setCourses(coursesData); if (user) { @@ -32,19 +32,19 @@ export default function CatalogPage() { setGamification(gamificationData); // Fetch deadlines for enrolled courses - const deadlines: { lesson: Lesson, courseTitle: string }[] = []; + const deadlines: { lesson: Lesson, courseTitle: string, courseId: 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 }); + deadlines.push({ lesson: l, courseTitle: outline.title, courseId: enrollment.course_id }); } }); }); } catch (err) { - console.error(`Failed to fetch outline for course ${enrollment.course_id}`, err); + console.error(`Failed to load 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)); @@ -182,8 +182,8 @@ export default function CatalogPage() { Upcoming Deadlines
- {upcomingDeadlines.map(({ lesson, courseTitle }) => ( - + {upcomingDeadlines.map(({ lesson, courseTitle, courseId }) => ( +
diff --git a/web/experience/src/components/InteractiveTranscript.tsx b/web/experience/src/components/InteractiveTranscript.tsx new file mode 100644 index 0000000..e6f7814 --- /dev/null +++ b/web/experience/src/components/InteractiveTranscript.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Clock } from "lucide-react"; + +interface Cue { + start: number; + end: number; + text: string; +} + +interface InteractiveTranscriptProps { + cues: Cue[]; + currentTime: number; + onSeek: (time: number) => void; +} + +export default function InteractiveTranscript({ cues, currentTime, onSeek }: InteractiveTranscriptProps) { + const scrollRef = useRef(null); + const activeCueRef = useRef(null); + + // Auto-scroll to active cue + useEffect(() => { + if (activeCueRef.current && scrollRef.current) { + activeCueRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + }, [currentTime]); + + const isCueActive = (cue: Cue) => { + return currentTime >= cue.start && currentTime < cue.end; + }; + + const formatTime = (time: number) => { + const mins = Math.floor(time / 60); + const secs = Math.floor(time % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
+
+ +

Interactive Transcript

+
+ +
+ {cues.length === 0 ? ( +
+ 🤐 +

No transcription available for this content

+
+ ) : ( + cues.map((cue, index) => { + const active = isCueActive(cue); + return ( +
onSeek(cue.start)} + className={`group cursor-pointer p-4 rounded-2xl transition-all border ${active + ? 'bg-blue-500/10 border-blue-500/30 text-white translate-x-1' + : 'bg-white/5 border-transparent text-gray-400 hover:bg-white/10 hover:border-white/10' + }`} + > +
+ + {formatTime(cue.start)} + +

+ {cue.text} +

+
+
+ ); + }) + )} +
+ +
+ Click any segment to jump +
+
+
+
+
+
+
+ ); +} diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index 58f7b65..bcdd48f 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -11,23 +11,40 @@ interface MediaPlayerProps { config?: { maxPlays?: number; }; + onTimeUpdate?: (time: number) => void; } -export default function MediaPlayer({ id, title, url, media_type, config }: MediaPlayerProps) { +export default function MediaPlayer({ id, title, url, media_type, config, onTimeUpdate }: MediaPlayerProps) { const [playCount, setPlayCount] = useState(0); + const [hasStarted, setHasStarted] = useState(false); const [locked, setLocked] = useState(false); const maxPlays = config?.maxPlays || 0; + const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; + + const getFullUrl = (path: string) => { + if (path.startsWith('http')) return path; + // Map /uploads to /assets for the backend + const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; + const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; + return `${CMS_API_URL}${finalPath}`; + }; + + const isLocalFile = url.startsWith('/uploads') || url.startsWith('http://localhost:3001/assets') || url.includes('/assets/'); + useEffect(() => { - if (maxPlays > 0 && playCount >= maxPlays) { + if (maxPlays > 0 && playCount >= maxPlays && !hasStarted) { setLocked(true); } - }, [playCount, maxPlays]); + }, [playCount, maxPlays, hasStarted]); const handlePlay = () => { if (locked) return; - setPlayCount(prev => prev + 1); + if (!hasStarted) { + setPlayCount(prev => prev + 1); + setHasStarted(true); + } }; if (locked) { @@ -72,18 +89,28 @@ export default function MediaPlayer({ id, title, url, media_type, config }: Medi
-