feat: Introduce multi-tenancy support with organization-specific data, add interactive transcript functionality, and enhance lesson/course schemas.

This commit is contained in:
2026-01-15 18:02:04 -03:00
parent daeda7e905
commit 663950aa0e
26 changed files with 933 additions and 302 deletions
+10 -7
View File
@@ -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:
+84 -33
View File
@@ -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 \
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
PAYLOAD=$(jq -n \
--arg email "$ADMIN_EMAIL" \
--arg password "$ADMIN_PASS" \
--arg full_name "System Admin" \
--arg org_name "Default Organization" \
--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}')
RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD")
RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD")
if echo "$RESPONSE" | grep -q "token"; then
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 ""
@@ -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;
@@ -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;
+96 -46
View File
@@ -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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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 *",
)
// 5. Update lesson
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
.bind(transcription)
.bind(id)
.fetch_one(&pool)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
.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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<UploadResponse>, (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
@@ -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",
@@ -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))
}
+40 -1
View File
@@ -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<sqlx::types::Uuid> = 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),
@@ -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;
@@ -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;
@@ -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 $$;
@@ -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();
@@ -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';
@@ -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 $$;
@@ -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';
+64 -26
View File
@@ -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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, 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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, 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<Uuid>,
) -> Result<Json<Vec<Enrollment>>, 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<Option<i32>> = 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<Json<Vec<common::models::UserGrade>>, 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)?;
+5
View File
@@ -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<DateTime<Utc>>,
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
pub transcription_status: Option<String>,
pub created_at: DateTime<Utc>,
}
#[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<GradingCategory>,
pub modules: Vec<PublishedModule>,
}
@@ -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<Lesson | null>(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<UserGrade | null>(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 <div className="p-20 text-center animate-pulse text-gray-500 font-bold uppercase tracking-widest">Loading Experience...</div>;
if (!lesson || !course) return <div className="p-20 text-center text-red-400">Content not found.</div>;
@@ -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 (
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
{/* Navigation Sidebar */}
@@ -88,16 +102,27 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col relative">
<div className="absolute top-4 left-4 z-10">
<main className="flex-1 flex flex-col relative overflow-hidden">
<div className="absolute top-4 left-4 z-10 flex gap-2">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-3 rounded-xl glass border-white/10 text-gray-400 hover:text-white transition-all bg-black/40"
title="Toggle Sidebar"
>
<Menu size={20} />
</button>
{hasTranscription && (
<button
onClick={() => setTranscriptOpen(!transcriptOpen)}
className={`p-3 rounded-xl glass border-white/10 transition-all bg-black/40 ${transcriptOpen ? 'text-blue-400' : 'text-gray-400 hover:text-white'}`}
title="Toggle Transcript"
>
<ListMusic size={20} />
</button>
)}
</div>
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-12">
<div className="max-w-4xl mx-auto space-y-20 pb-40">
<div className="space-y-4">
@@ -133,6 +158,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
url={block.url || ""}
media_type={block.media_type || 'video'}
config={block.config}
onTimeUpdate={setCurrentTime}
/>
)}
{block.type === 'quiz' && (
@@ -212,6 +238,18 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
</div>
{/* Interactive Transcript Panel */}
{hasTranscription && transcriptOpen && (
<aside className="w-[400px] border-l border-white/5 bg-black/20 animate-in slide-in-from-right duration-500">
<InteractiveTranscript
cues={lesson.transcription!.cues!}
currentTime={currentTime}
onSeek={handleSeek}
/>
</aside>
)}
</div>
{/* Footer Controls */}
<footer className="h-20 glass border-t border-white/5 px-6 flex items-center justify-between bg-black/60 backdrop-blur-3xl shrink-0">
{prevLesson ? (
+7 -7
View File
@@ -13,7 +13,7 @@ export default function CatalogPage() {
const [enrollments, setEnrollments] = useState<string[]>([]);
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() {
<Calendar size={14} /> Upcoming Deadlines
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{upcomingDeadlines.map(({ lesson, courseTitle }) => (
<Link key={lesson.id} href={`/courses/${lesson.module_id}/lessons/${lesson.id}`} className="group">
{upcomingDeadlines.map(({ lesson, courseTitle, courseId }) => (
<Link key={lesson.id} href={`/courses/${courseId}/lessons/${lesson.id}`} className="block group">
<div className="glass-card p-6 border-blue-500/10 bg-blue-500/2 rounded-3xl hover:border-blue-500/30 transition-all">
<div className="flex justify-between items-start mb-4">
<div className="text-[10px] font-black uppercase tracking-widest text-blue-400 group-hover:text-blue-300 transition-colors">
@@ -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<HTMLDivElement>(null);
const activeCueRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full glass-card overflow-hidden border-white/5 bg-black/20">
<div className="p-6 border-b border-white/5 flex items-center gap-3 bg-white/5">
<Clock className="w-4 h-4 text-blue-400" />
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Interactive Transcript</h3>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar"
>
{cues.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<span className="text-4xl mb-4">🤐</span>
<p className="text-xs text-gray-500 uppercase tracking-widest font-bold">No transcription available for this content</p>
</div>
) : (
cues.map((cue, index) => {
const active = isCueActive(cue);
return (
<div
key={index}
ref={active ? activeCueRef : null}
onClick={() => 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'
}`}
>
<div className="flex items-start gap-4">
<span className={`text-[10px] font-mono mt-1 ${active ? 'text-blue-400' : 'text-gray-600'}`}>
{formatTime(cue.start)}
</span>
<p className={`text-sm leading-relaxed ${active ? 'font-medium' : ''}`}>
{cue.text}
</p>
</div>
</div>
);
})
)}
</div>
<div className="p-4 bg-white/5 border-t border-white/5 flex items-center justify-between">
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Click any segment to jump</span>
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full bg-blue-500 animate-pulse"></div>
<div className="w-1 h-1 rounded-full bg-blue-500/50"></div>
<div className="w-1 h-1 rounded-full bg-blue-500/20"></div>
</div>
</div>
</div>
);
}
@@ -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;
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
</div>
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
{isLocalFile ? (
<video
src={getFullUrl(url)}
controls
className="w-full h-full rounded-xl"
onPlay={handlePlay}
onTimeUpdate={(e) => {
if (onTimeUpdate) {
onTimeUpdate(e.currentTarget.currentTime);
}
}}
/>
) : (
<iframe
src={getEmbedUrl(url)}
className="w-full h-full rounded-xl"
allowFullScreen
onLoad={() => {
// In a real app, we'd detect play events from the player API
// For this demo, we'll increment when the iframe loads or specific interaction
}}
/>
)}
{/* Simulated play tracker overlay (invisible but catches first click) */}
{playCount === 0 && (
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
{!isLocalFile && playCount === 0 && (
<div
onClick={handlePlay}
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-black/20 transition-all"
+2 -1
View File
@@ -96,6 +96,7 @@ export interface User {
email: string;
full_name: string;
role: string;
organization_id: string;
xp?: number;
level?: number;
}
@@ -127,7 +128,7 @@ export interface Module {
lessons: Lesson[];
}
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null;
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
const token = getToken();
@@ -24,6 +24,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]);
@@ -39,8 +40,58 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [dueDate, setDueDate] = useState<string>("");
const [importantDateType, setImportantDateType] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [transcriptionTimer, setTranscriptionTimer] = useState(0);
// Polling for AI status
useEffect(() => {
let interval: NodeJS.Timeout;
if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) {
interval = setInterval(async () => {
try {
const updated = await cmsApi.getLesson(params.lessonId);
setLesson(updated);
// If it finished, update local states
if (updated.transcription_status === 'completed') {
if (updated.transcription) {
// Automatically update summary if available? No, wait for manual trigger or auto-trigger?
// For now just update lesson
}
}
} catch (err) {
console.error("Polling failed", err);
}
}, 3000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [lesson, lesson?.transcription_status, params.lessonId]);
// Timer logic
useEffect(() => {
let timerInterval: NodeJS.Timeout;
if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) {
timerInterval = setInterval(() => {
setTranscriptionTimer(prev => prev + 1);
}, 1000);
} else {
setTranscriptionTimer(0);
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
}, [lesson, lesson?.transcription_status]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
useEffect(() => {
const loadData = async () => {
@@ -95,8 +146,18 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
if (!lesson) return;
setIsSaving(true);
try {
// Sync content_url for video/audio lessons from the first media block
let content_url = lesson.content_url;
if (lesson.content_type === 'video' || lesson.content_type === 'audio') {
const mediaBlock = blocks.find(b => b.type === 'media');
if (mediaBlock && mediaBlock.url) {
content_url = mediaBlock.url;
}
}
const updated = await cmsApi.updateLesson(lesson.id, {
metadata: { ...lesson.metadata, blocks },
content_url,
summary,
is_graded: isGraded,
grading_category_id: selectedCategoryId || null,
@@ -381,19 +442,35 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
<button
onClick={handleTranscribe}
disabled={isTranscribing}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${lesson.transcription ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400 hover:border-blue-500/60'}`}
disabled={isTranscribing || lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing'}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 relative overflow-hidden ${lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing'
? 'bg-blue-500/20 border-blue-500/50 text-blue-300 animate-pulse'
: lesson.transcription_status === 'completed' || lesson.transcription
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-blue-500/10 border-blue-500/30 text-blue-400 hover:border-blue-500/60'
}`}
>
<span className="text-xl">{isTranscribing ? '⏳' : '🎤'}</span>
<span className="text-xl">
{lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing' ? '⏳' : '🎤'}
</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Video/Audio</div>
<div className="font-bold">{isTranscribing ? 'Transcribing...' : lesson.transcription ? 'Update Transcript' : 'Transcribe Video'}</div>
<div className="font-bold">
{lesson.transcription_status === 'queued'
? `Queued (${formatTime(transcriptionTimer)})`
: lesson.transcription_status === 'processing'
? `Transcribing (${formatTime(transcriptionTimer)})`
: lesson.transcription ? 'Update Transcript' : 'Transcribe Video'}
</div>
{(lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing') && (
<div className="absolute bottom-0 left-0 h-1 bg-blue-500 animate-[progress_2s_ease-in-out_infinite]" style={{ width: '100%' }}></div>
)}
</button>
)}
<button
onClick={handleSummarize}
disabled={isGeneratingSummary || !lesson.transcription}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60 disabled:opacity-30 disabled:cursor-not-allowed'}`}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${isGeneratingSummary ? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-300 animate-pulse' : summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60 disabled:opacity-30 disabled:cursor-not-allowed'}`}
>
<span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div>
@@ -404,7 +481,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<button
onClick={handleGenerateQuiz}
disabled={isGeneratingQuiz || !lesson.transcription}
className="p-6 bg-purple-500/10 border border-purple-500/30 hover:border-purple-500/60 rounded-2xl transition-all text-left flex flex-col gap-2 text-purple-400 disabled:opacity-30 disabled:cursor-not-allowed"
className={`p-6 border rounded-2xl transition-all text-left flex flex-col gap-2 ${isGeneratingQuiz ? 'bg-purple-500/20 border-purple-500/50 text-purple-300 animate-pulse' : 'bg-purple-500/10 border-purple-500/30 hover:border-purple-500/60 text-purple-400 disabled:opacity-30 disabled:cursor-not-allowed'}`}
>
<span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
+44 -12
View File
@@ -11,19 +11,26 @@ interface FileUploadProps {
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadingFileName, setUploadingFileName] = useState("");
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (file: File) => {
setIsUploading(true);
setUploadProgress(0);
setUploadingFileName(file.name);
try {
const result = await cmsApi.uploadAsset(file);
const result = await cmsApi.uploadAsset(file, (pct) => {
setUploadProgress(pct);
});
onUploadComplete(result.url);
} catch (err) {
alert("Upload failed. Please try again.");
console.error(err);
} finally {
setIsUploading(false);
setUploadingFileName("");
}
};
@@ -54,14 +61,47 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid
return (
<div className="space-y-4">
{/* Upload Progress Modal Overlay */}
{isUploading && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-md bg-gray-900 border border-white/10 p-8 rounded-3xl shadow-2xl space-y-6 text-center">
<div className="w-20 h-20 bg-blue-500/10 border border-blue-500/20 rounded-full flex items-center justify-center mx-auto animate-pulse">
<span className="text-3xl">📤</span>
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold text-white tracking-tight">Uploading Asset</h3>
<p className="text-xs text-gray-400 font-medium truncate px-4">{uploadingFileName}</p>
</div>
<div className="space-y-4">
<div className="h-3 w-full bg-white/5 rounded-full overflow-hidden border border-white/5 shadow-inner">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out shadow-[0_0_15px_rgba(59,130,246,0.5)]"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className="flex justify-between items-center px-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-400">Processing...</span>
<span className="text-lg font-black italic text-white">{uploadProgress}%</span>
</div>
</div>
<p className="text-[10px] text-gray-500 uppercase tracking-widest leading-relaxed">
Please do not close this tab or navigate away. <br /> Your file is being securely transferred.
</p>
</div>
</div>
)}
<div
className={`relative group cursor-pointer border-2 border-dashed rounded-xl p-8 transition-all flex flex-col items-center justify-center gap-4 ${dragActive ? "border-blue-500 bg-blue-500/10 scale-[1.02]" : "border-white/10 hover:border-white/20 bg-white/5"
}`}
} ${isUploading ? "opacity-30 pointer-events-none" : ""}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
onClick={() => !isUploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
@@ -69,15 +109,9 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid
className="hidden"
accept={accept}
onChange={handleFileChange}
disabled={isUploading}
/>
{isUploading ? (
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-xs font-bold uppercase tracking-widest text-blue-400">Uploading Asset...</span>
</div>
) : (
<>
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
</div>
@@ -85,8 +119,6 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid
<p className="text-sm font-bold text-gray-300">Drag & drop or <span className="text-blue-400 underline decoration-blue-500/30">browse</span></p>
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Native video, audio files supported</p>
</div>
</>
)}
</div>
{currentUrl && !isUploading && (
@@ -3,6 +3,7 @@
import { useState } from "react";
import MediaPlayer from "../MediaPlayer";
import FileUpload from "../FileUpload";
import { getImageUrl } from "@/lib/api";
interface MediaBlockProps {
id: string;
@@ -37,7 +38,7 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
};
// Full URL for display (handles relative paths from server)
const displayUrl = url.startsWith("/") ? `http://localhost:3001${url}` : url;
const displayUrl = getImageUrl(url);
return (
<div className="space-y-6">
+29 -13
View File
@@ -63,6 +63,7 @@ export interface Lesson {
module_id: string;
title: string;
content_type: string;
content_url?: string;
metadata?: {
blocks: Block[];
};
@@ -78,6 +79,7 @@ export interface Lesson {
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
created_at: string;
}
@@ -271,26 +273,40 @@ export const cmsApi = {
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
// Assets
uploadAsset: (file: File): Promise<UploadResponse> => {
uploadAsset: (file: File, onProgress?: (pct: number) => void): Promise<UploadResponse> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/assets/upload`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
const selectedOrgId = getSelectedOrgId();
const headers: Record<string, string> = {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
if (selectedOrgId) xhr.setRequestHeader('X-Organization-Id', selectedOrgId);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
onProgress(percentComplete);
}
};
// Note: We don't set 'Content-Type' for multipart/form-data.
// The browser will set it automatically with the correct boundary.
return fetch(`${API_BASE_URL}/assets/upload`, {
method: 'POST',
headers,
body: formData,
}).then(res => {
if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Upload failed')));
return res.json();
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
let msg = 'Upload failed';
try {
msg = JSON.parse(xhr.responseText).message || msg;
} catch { }
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
},
// Organizations Branding