feat: Introduce multi-tenancy support with organization-specific data, add interactive transcript functionality, and enhance lesson/course schemas.
This commit is contained in:
+10
-7
@@ -21,9 +21,12 @@ services:
|
|||||||
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
||||||
JWT_SECRET: openccb_secret_key_2025_production
|
JWT_SECRET: openccb_secret_key_2025_production
|
||||||
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
||||||
|
LMS_INTERNAL_URL: http://experience:3002
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
@@ -51,13 +54,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DEVICE=${WHISPER_DEVICE:-cpu}
|
- DEVICE=${WHISPER_DEVICE:-cpu}
|
||||||
# GPU support for RTX 2070 Super
|
# GPU support for RTX 2070 Super
|
||||||
deploy:
|
#deploy:
|
||||||
resources:
|
# resources:
|
||||||
reservations:
|
# reservations:
|
||||||
devices:
|
# devices:
|
||||||
- driver: nvidia
|
# - driver: nvidia
|
||||||
count: 1
|
# count: 1
|
||||||
capabilities: [ gpu ]
|
# capabilities: [ gpu ]
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+84
-33
@@ -113,29 +113,38 @@ if [ "$HAS_NVIDIA" = true ]; then
|
|||||||
update_env "WHISPER_DEVICE" "cuda"
|
update_env "WHISPER_DEVICE" "cuda"
|
||||||
update_env "LOCAL_LLM_MODEL" "llama3:8b"
|
update_env "LOCAL_LLM_MODEL" "llama3:8b"
|
||||||
# Uncomment GPU deploy section in docker-compose.yml while preserving indentation
|
# Uncomment GPU deploy section in docker-compose.yml while preserving indentation
|
||||||
sed -i '/deploy:/s/# //' docker-compose.yml
|
sed -i 's/^ #deploy:/ deploy:/' docker-compose.yml
|
||||||
sed -i '/resources:/s/# //' docker-compose.yml
|
sed -i 's/^ # resources:/ resources:/' docker-compose.yml
|
||||||
sed -i '/reservations:/s/# //' docker-compose.yml
|
sed -i 's/^ # reservations:/ reservations:/' docker-compose.yml
|
||||||
sed -i '/devices:/s/# //' docker-compose.yml
|
sed -i 's/^ # devices:/ devices:/' docker-compose.yml
|
||||||
sed -i '/- driver: nvidia/s/# //' docker-compose.yml
|
sed -i 's/^ # - driver: nvidia/ - driver: nvidia/' docker-compose.yml
|
||||||
sed -i '/count: 1/s/# //' docker-compose.yml
|
sed -i 's/^ # count: 1/ count: 1/' docker-compose.yml
|
||||||
sed -i '/capabilities: \[ gpu \]/s/# //' docker-compose.yml
|
sed -i 's/^ # capabilities: \[ gpu \]/ capabilities: [ gpu ]/' docker-compose.yml
|
||||||
else
|
else
|
||||||
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu"
|
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu"
|
||||||
update_env "WHISPER_DEVICE" "cpu"
|
update_env "WHISPER_DEVICE" "cpu"
|
||||||
update_env "LOCAL_LLM_MODEL" "phi3:mini"
|
update_env "LOCAL_LLM_MODEL" "phi3:mini"
|
||||||
# Ensure it's commented (if it was previously uncommented)
|
# Comment GPU deploy section in docker-compose.yml
|
||||||
# (Simple approach: we leave it as is or explicitly comment it out)
|
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
|
fi
|
||||||
|
|
||||||
# Ask for DB credentials if not set
|
# Ask for DB credentials if not set
|
||||||
if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then
|
if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then
|
||||||
read -p "Enter Database Password [password]: " DB_PASS
|
read -p "Enter Database Password [password]: " DB_PASS
|
||||||
DB_PASS=${DB_PASS:-password}
|
DB_PASS=${DB_PASS:-password}
|
||||||
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb"
|
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"
|
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"
|
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms?sslmode=disable"
|
||||||
update_env "JWT_SECRET" "supersecretsecret"
|
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_CMS_API_URL" "http://localhost:3001"
|
||||||
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002"
|
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002"
|
||||||
fi
|
fi
|
||||||
@@ -161,12 +170,40 @@ fi
|
|||||||
|
|
||||||
# 6. Database Initialization (Integrated db-mgmt.sh)
|
# 6. Database Initialization (Integrated db-mgmt.sh)
|
||||||
echo ""
|
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..."
|
echo "🐘 Starting database with Docker..."
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
sleep 5 # Simple wait
|
|
||||||
|
|
||||||
CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2)
|
echo "⏳ Waiting for database to be ready..."
|
||||||
LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2)
|
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..."
|
echo "🏗️ Creating databases and running migrations..."
|
||||||
DATABASE_URL=$CMS_URL sqlx database create || true
|
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)
|
# 7. System Initialization (Integrated init-system.sh)
|
||||||
echo ""
|
echo ""
|
||||||
echo "👤 Creating Initial Administrator..."
|
echo "🔍 Checking for existing administrator..."
|
||||||
API_URL="http://localhost:3001"
|
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")
|
||||||
# Start the Studio service (which contains CMS) to allow admin creation
|
|
||||||
echo "🚀 Starting services to allow admin creation..."
|
if [ "$ADMIN_EXISTS" != "t" ]; then
|
||||||
docker compose up -d --build
|
echo "👤 Configure Initial Administrator"
|
||||||
echo "⏳ Waiting for CMS API to be ready..."
|
read -p "Full Name [System Admin]: " ADMIN_NAME
|
||||||
START_WAIT=$SECONDS
|
ADMIN_NAME=${ADMIN_NAME:-System Admin}
|
||||||
until curl -s "$API_URL/auth/login" &> /dev/null || [ $((SECONDS - START_WAIT)) -gt 60 ]; do sleep 2; done
|
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 ""
|
||||||
|
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 email "$ADMIN_EMAIL" \
|
||||||
--arg password "$ADMIN_PASS" \
|
--arg password "$ADMIN_PASS" \
|
||||||
--arg full_name "System Admin" \
|
--arg full_name "$ADMIN_NAME" \
|
||||||
--arg org_name "Default Organization" \
|
--arg org_name "$ORG_NAME" \
|
||||||
--arg role "admin" \
|
--arg role "admin" \
|
||||||
'{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}')
|
'{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."
|
echo "✅ Success! Administrator created."
|
||||||
|
else
|
||||||
|
echo "⚠️ Failed to create administrator."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "⚠️ Failed to create administrator (it might already exist)."
|
echo "✅ Administrator already exists. Skipping registration."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
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;
|
||||||
@@ -51,7 +51,16 @@ pub async fn publish_course(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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 {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>(
|
let lessons = sqlx::query_as::<_, Lesson>(
|
||||||
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
|
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
|
||||||
@@ -66,15 +75,16 @@ pub async fn publish_course(
|
|||||||
|
|
||||||
let payload = PublishedCourse {
|
let payload = PublishedCourse {
|
||||||
course,
|
course,
|
||||||
|
organization,
|
||||||
grading_categories,
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Send to LMS
|
// 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 client = reqwest::Client::new();
|
||||||
let res = client
|
let res = client
|
||||||
.post("http://lms-service:3002/ingest")
|
.post(format!("{}/ingest", lms_url))
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -105,7 +115,7 @@ pub async fn publish_course(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -489,6 +499,7 @@ pub async fn process_transcription(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
tracing::info!("Received transcription request for lesson: {}", id);
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -508,13 +519,63 @@ pub async fn process_transcription(
|
|||||||
let filename = url.trim_start_matches("/assets/");
|
let filename = url.trim_start_matches("/assets/");
|
||||||
let file_path = format!("uploads/{}", filename);
|
let file_path = format!("uploads/{}", filename);
|
||||||
|
|
||||||
// 2. Read file
|
// 2. Read file to verify it exists
|
||||||
let file_data = tokio::fs::read(&file_path).await.map_err(|e| {
|
if !tokio::fs::metadata(&file_path).await.is_ok() {
|
||||||
tracing::error!("File read failed ({}): {}", file_path, e);
|
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
|
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 provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -527,7 +588,7 @@ pub async fn process_transcription(
|
|||||||
"medium".to_string(),
|
"medium".to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} 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(),
|
"https://api.openai.com/v1/audio/transcriptions".to_string(),
|
||||||
format!("Bearer {}", api_key),
|
format!("Bearer {}", api_key),
|
||||||
@@ -538,7 +599,7 @@ pub async fn process_transcription(
|
|||||||
let part = reqwest::multipart::Part::bytes(file_data)
|
let part = reqwest::multipart::Part::bytes(file_data)
|
||||||
.file_name(filename.to_string())
|
.file_name(filename.to_string())
|
||||||
.mime_str("application/octet-stream")
|
.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()
|
let form = reqwest::multipart::Form::new()
|
||||||
.part("file", part)
|
.part("file", part)
|
||||||
@@ -550,21 +611,20 @@ pub async fn process_transcription(
|
|||||||
request = request.header("Authorization", auth_header);
|
request = request.header("Authorization", auth_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = request.send().await.map_err(|e| {
|
let response = request
|
||||||
tracing::error!("Transcription request failed: {}", e);
|
.send()
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
.await
|
||||||
})?;
|
.map_err(|e| format!("Transcription request failed: {}", e))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let err_body = response.text().await.unwrap_or_default();
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
tracing::error!("Transcription API error: {}", err_body);
|
return Err(format!("Transcription API error: {}", err_body));
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let whisper_data: serde_json::Value = response.json().await.map_err(|e| {
|
let whisper_data: serde_json::Value = response
|
||||||
tracing::error!("Whisper JSON parse failed: {}", e);
|
.json()
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
.await
|
||||||
})?;
|
.map_err(|e| format!("Whisper JSON parse failed: {}", e))?;
|
||||||
|
|
||||||
// Extract text and segments (cues)
|
// Extract text and segments (cues)
|
||||||
let text = whisper_data["text"].as_str().unwrap_or_default();
|
let text = whisper_data["text"].as_str().unwrap_or_default();
|
||||||
@@ -583,35 +643,19 @@ pub async fn process_transcription(
|
|||||||
|
|
||||||
let transcription = json!({
|
let transcription = json!({
|
||||||
"en": text,
|
"en": text,
|
||||||
"es": "", // Could add a translation step here
|
"es": "",
|
||||||
"cues": cues
|
"cues": cues
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Update lesson
|
// 5. Update lesson
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
|
||||||
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *",
|
|
||||||
)
|
|
||||||
.bind(transcription)
|
.bind(transcription)
|
||||||
.bind(id)
|
.bind(lesson_id)
|
||||||
.fetch_one(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| format!("Final database update failed: {}", e))?;
|
||||||
tracing::error!("Database update failed: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log_action(
|
Ok(())
|
||||||
&pool,
|
|
||||||
org_ctx.id,
|
|
||||||
claims.sub,
|
|
||||||
"TRANSCRIPTION_PROCESSED",
|
|
||||||
"Lesson",
|
|
||||||
id,
|
|
||||||
json!({}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(Json(updated_lesson))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn summarize_lesson(
|
pub async fn summarize_lesson(
|
||||||
@@ -620,6 +664,7 @@ pub async fn summarize_lesson(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
tracing::info!("Received summarization request for lesson: {}", id);
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -731,6 +776,7 @@ pub async fn generate_quiz(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
tracing::info!("Received quiz generation request for lesson: {}", id);
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -1229,6 +1275,7 @@ pub async fn upload_asset(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
mut multipart: axum::extract::Multipart,
|
mut multipart: axum::extract::Multipart,
|
||||||
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
||||||
|
tracing::info!("Starting upload_asset for org: {}", org_ctx.id);
|
||||||
let mut filename = String::new();
|
let mut filename = String::new();
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
let mut mimetype = String::new();
|
let mut mimetype = String::new();
|
||||||
@@ -1302,6 +1349,7 @@ pub async fn upload_asset(
|
|||||||
|
|
||||||
let url = format!("/assets/{}", storage_filename);
|
let url = format!("/assets/{}", storage_filename);
|
||||||
|
|
||||||
|
tracing::info!("Upload successful: {} -> {}", filename, url);
|
||||||
Ok(Json(UploadResponse {
|
Ok(Json(UploadResponse {
|
||||||
id: asset_id,
|
id: asset_id,
|
||||||
filename,
|
filename,
|
||||||
@@ -1451,8 +1499,9 @@ pub async fn get_course_analytics(
|
|||||||
|
|
||||||
// 4. Fetch from LMS
|
// 4. Fetch from LMS
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string());
|
||||||
let res = client
|
let res = client
|
||||||
.get(format!("http://lms-service:3002/courses/{}/analytics", id))
|
.get(format!("{}/courses/{}/analytics", lms_url, id))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||||
@@ -1497,10 +1546,11 @@ pub async fn get_advanced_analytics(
|
|||||||
|
|
||||||
// 4. Fetch from LMS
|
// 4. Fetch from LMS
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string());
|
||||||
let res = client
|
let res = client
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"http://lms-service:3002/courses/{}/analytics/advanced",
|
"{}/courses/{}/analytics/advanced",
|
||||||
id
|
lms_url, id
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ pub async fn upload_organization_logo(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
claims.org,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"UPDATE_LOGO",
|
"UPDATE_LOGO",
|
||||||
"Organization",
|
"Organization",
|
||||||
@@ -197,6 +198,7 @@ pub async fn update_organization_branding(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
claims.org,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"UPDATE_BRANDING",
|
"UPDATE_BRANDING",
|
||||||
"Organization",
|
"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(
|
pub async fn update_module(
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
@@ -24,7 +35,7 @@ pub async fn update_module(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
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))
|
Ok(Json(updated_module))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ mod webhooks;
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -25,12 +27,48 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
// Run migrations automatically
|
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to run migrations");
|
.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()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
@@ -92,6 +130,7 @@ async fn main() {
|
|||||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/assets/upload", post(handlers::upload_asset))
|
.route("/assets/upload", post(handlers::upload_asset))
|
||||||
|
.layer(DefaultBodyLimit::disable())
|
||||||
.route(
|
.route(
|
||||||
"/organizations",
|
"/organizations",
|
||||||
get(handlers::get_organizations).post(handlers::create_organization),
|
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 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 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;
|
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';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
@@ -251,11 +251,40 @@ pub async fn ingest_course(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 1. Upsert Course
|
// 1. Upsert Organization
|
||||||
let org_id = payload.course.organization_id;
|
let org_id = payload.course.organization_id;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id)
|
"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, $10)
|
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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
@@ -265,7 +294,8 @@ pub async fn ingest_course(
|
|||||||
passing_percentage = EXCLUDED.passing_percentage,
|
passing_percentage = EXCLUDED.passing_percentage,
|
||||||
certificate_template = EXCLUDED.certificate_template,
|
certificate_template = EXCLUDED.certificate_template,
|
||||||
updated_at = EXCLUDED.updated_at,
|
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.id)
|
||||||
.bind(&payload.course.title)
|
.bind(&payload.course.title)
|
||||||
@@ -277,6 +307,7 @@ pub async fn ingest_course(
|
|||||||
.bind(&payload.course.certificate_template)
|
.bind(&payload.course.certificate_template)
|
||||||
.bind(payload.course.updated_at)
|
.bind(payload.course.updated_at)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
|
.bind(&payload.course.pacing_mode)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -333,8 +364,8 @@ pub async fn ingest_course(
|
|||||||
|
|
||||||
for lesson in pub_module.lessons {
|
for lesson in pub_module.lessons {
|
||||||
sqlx::query(
|
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)
|
"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)"
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
|
||||||
)
|
)
|
||||||
.bind(lesson.id)
|
.bind(lesson.id)
|
||||||
.bind(pub_module.module.id)
|
.bind(pub_module.module.id)
|
||||||
@@ -350,6 +381,10 @@ pub async fn ingest_course(
|
|||||||
.bind(lesson.max_attempts)
|
.bind(lesson.max_attempts)
|
||||||
.bind(lesson.allow_retry)
|
.bind(lesson.allow_retry)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
|
.bind(&lesson.summary)
|
||||||
|
.bind(lesson.due_date)
|
||||||
|
.bind(&lesson.important_date_type)
|
||||||
|
.bind(&lesson.transcription_status)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -371,43 +406,49 @@ pub async fn get_course_outline(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
||||||
|
tracing::info!("get_course_outline: fetching course {}", id);
|
||||||
// 1. Fetch Course
|
// 1. Fetch Course
|
||||||
let 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(id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Fetch Modules
|
// 2. Fetch Modules
|
||||||
let modules = sqlx::query_as::<_, Module>(
|
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(id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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>(
|
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at",
|
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 4. Fetch Lessons
|
// 5. Fetch Lessons
|
||||||
let mut pub_modules = Vec::new();
|
let mut pub_modules = Vec::new();
|
||||||
for module in modules {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>(
|
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(module.id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -417,6 +458,7 @@ pub async fn get_course_outline(
|
|||||||
|
|
||||||
Ok(Json(common::models::PublishedCourse {
|
Ok(Json(common::models::PublishedCourse {
|
||||||
course,
|
course,
|
||||||
|
organization,
|
||||||
grading_categories,
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
}))
|
}))
|
||||||
@@ -427,10 +469,10 @@ pub async fn get_lesson_content(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
tracing::info!("get_lesson_content: fetching lesson {}", id);
|
||||||
let lesson =
|
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(id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -444,10 +486,9 @@ pub async fn get_user_enrollments(
|
|||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
||||||
let enrollments = sqlx::query_as::<_, Enrollment>(
|
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(user_id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -491,10 +532,9 @@ pub async fn submit_lesson_score(
|
|||||||
|
|
||||||
// 1. Get lesson attempt rules
|
// 1. Get lesson attempt rules
|
||||||
let max_attempts: Option<Option<i32>> = sqlx::query_scalar(
|
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(payload.lesson_id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -560,8 +600,7 @@ pub async fn submit_lesson_score(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Detect course completion logic
|
// 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)")
|
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)")
|
||||||
.bind(org_ctx.id)
|
|
||||||
.bind(payload.course_id)
|
.bind(payload.course_id)
|
||||||
.fetch_one(&pool).await.unwrap_or(0);
|
.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)>,
|
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
||||||
let grades = sqlx::query_as::<_, common::models::UserGrade>(
|
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(user_id)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct Course {
|
|||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Module {
|
pub struct Module {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub course_id: Uuid,
|
pub course_id: Uuid,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
@@ -31,6 +32,7 @@ pub struct Module {
|
|||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Lesson {
|
pub struct Lesson {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub module_id: Uuid,
|
pub module_id: Uuid,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
@@ -45,12 +47,14 @@ pub struct Lesson {
|
|||||||
pub position: i32,
|
pub position: i32,
|
||||||
pub due_date: Option<DateTime<Utc>>,
|
pub due_date: Option<DateTime<Utc>>,
|
||||||
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
|
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
|
||||||
|
pub transcription_status: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
pub struct GradingCategory {
|
pub struct GradingCategory {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
pub course_id: Uuid,
|
pub course_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub weight: i32, // 0-100
|
pub weight: i32, // 0-100
|
||||||
@@ -160,6 +164,7 @@ pub struct AuthResponse {
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct PublishedCourse {
|
pub struct PublishedCourse {
|
||||||
pub course: Course,
|
pub course: Course,
|
||||||
|
pub organization: Organization,
|
||||||
pub grading_categories: Vec<GradingCategory>,
|
pub grading_categories: Vec<GradingCategory>,
|
||||||
pub modules: Vec<PublishedModule>,
|
pub modules: Vec<PublishedModule>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import FillInTheBlanksPlayer from "@/components/blocks/FillInTheBlanksPlayer";
|
|||||||
import MatchingPlayer from "@/components/blocks/MatchingPlayer";
|
import MatchingPlayer from "@/components/blocks/MatchingPlayer";
|
||||||
import OrderingPlayer from "@/components/blocks/OrderingPlayer";
|
import OrderingPlayer from "@/components/blocks/OrderingPlayer";
|
||||||
import ShortAnswerPlayer from "@/components/blocks/ShortAnswerPlayer";
|
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 } }) {
|
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = 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 [userGrade, setUserGrade] = useState<UserGrade | null>(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -44,7 +48,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchAll();
|
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 (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>;
|
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 prevLesson = allLessons[currentIndex - 1];
|
||||||
const nextLesson = 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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
@@ -88,16 +102,27 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 flex flex-col relative">
|
<main className="flex-1 flex flex-col relative overflow-hidden">
|
||||||
<div className="absolute top-4 left-4 z-10">
|
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="p-3 rounded-xl glass border-white/10 text-gray-400 hover:text-white transition-all bg-black/40"
|
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} />
|
<Menu size={20} />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-12">
|
<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="max-w-4xl mx-auto space-y-20 pb-40">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -133,6 +158,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
url={block.url || ""}
|
url={block.url || ""}
|
||||||
media_type={block.media_type || 'video'}
|
media_type={block.media_type || 'video'}
|
||||||
config={block.config}
|
config={block.config}
|
||||||
|
onTimeUpdate={setCurrentTime}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{block.type === 'quiz' && (
|
{block.type === 'quiz' && (
|
||||||
@@ -212,6 +238,18 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</div>
|
</div>
|
||||||
</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 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">
|
<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 ? (
|
{prevLesson ? (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function CatalogPage() {
|
|||||||
const [enrollments, setEnrollments] = useState<string[]>([]);
|
const [enrollments, setEnrollments] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [gamification, setGamification] = useState<{ points: number, level: number, badges: any[] } | null>(null);
|
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 { user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +21,7 @@ export default function CatalogPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const coursesData = await lmsApi.getCatalog();
|
const coursesData = await lmsApi.getCatalog(user?.organization_id);
|
||||||
setCourses(coursesData);
|
setCourses(coursesData);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -32,19 +32,19 @@ export default function CatalogPage() {
|
|||||||
setGamification(gamificationData);
|
setGamification(gamificationData);
|
||||||
|
|
||||||
// Fetch deadlines for enrolled courses
|
// Fetch deadlines for enrolled courses
|
||||||
const deadlines: { lesson: Lesson, courseTitle: string }[] = [];
|
const deadlines: { lesson: Lesson, courseTitle: string, courseId: string }[] = [];
|
||||||
for (const enrollment of enrollmentData) {
|
for (const enrollment of enrollmentData) {
|
||||||
try {
|
try {
|
||||||
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
|
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
|
||||||
outline.modules.forEach(mod => {
|
outline.modules.forEach(mod => {
|
||||||
mod.lessons.forEach(l => {
|
mod.lessons.forEach(l => {
|
||||||
if (l.due_date && new Date(l.due_date) >= new Date()) {
|
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) {
|
} 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));
|
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
|
<Calendar size={14} /> Upcoming Deadlines
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{upcomingDeadlines.map(({ lesson, courseTitle }) => (
|
{upcomingDeadlines.map(({ lesson, courseTitle, courseId }) => (
|
||||||
<Link key={lesson.id} href={`/courses/${lesson.module_id}/lessons/${lesson.id}`} className="group">
|
<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="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="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">
|
<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?: {
|
config?: {
|
||||||
maxPlays?: number;
|
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 [playCount, setPlayCount] = useState(0);
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
const maxPlays = config?.maxPlays || 0;
|
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(() => {
|
useEffect(() => {
|
||||||
if (maxPlays > 0 && playCount >= maxPlays) {
|
if (maxPlays > 0 && playCount >= maxPlays && !hasStarted) {
|
||||||
setLocked(true);
|
setLocked(true);
|
||||||
}
|
}
|
||||||
}, [playCount, maxPlays]);
|
}, [playCount, maxPlays, hasStarted]);
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
|
if (!hasStarted) {
|
||||||
setPlayCount(prev => prev + 1);
|
setPlayCount(prev => prev + 1);
|
||||||
|
setHasStarted(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -72,18 +89,28 @@ export default function MediaPlayer({ id, title, url, media_type, config }: Medi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
|
<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
|
<iframe
|
||||||
src={getEmbedUrl(url)}
|
src={getEmbedUrl(url)}
|
||||||
className="w-full h-full rounded-xl"
|
className="w-full h-full rounded-xl"
|
||||||
allowFullScreen
|
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) */}
|
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
|
||||||
{playCount === 0 && (
|
{!isLocalFile && playCount === 0 && (
|
||||||
<div
|
<div
|
||||||
onClick={handlePlay}
|
onClick={handlePlay}
|
||||||
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-black/20 transition-all"
|
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-black/20 transition-all"
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
organization_id: string;
|
||||||
xp?: number;
|
xp?: number;
|
||||||
level?: number;
|
level?: number;
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ export interface Module {
|
|||||||
lessons: Lesson[];
|
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 apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Activity State (Blocks)
|
// Activity State (Blocks)
|
||||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||||
@@ -39,8 +40,58 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const [dueDate, setDueDate] = useState<string>("");
|
const [dueDate, setDueDate] = useState<string>("");
|
||||||
const [importantDateType, setImportantDateType] = useState<string>("");
|
const [importantDateType, setImportantDateType] = useState<string>("");
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [editValue, setEditValue] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -95,8 +146,18 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
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, {
|
const updated = await cmsApi.updateLesson(lesson.id, {
|
||||||
metadata: { ...lesson.metadata, blocks },
|
metadata: { ...lesson.metadata, blocks },
|
||||||
|
content_url,
|
||||||
summary,
|
summary,
|
||||||
is_graded: isGraded,
|
is_graded: isGraded,
|
||||||
grading_category_id: selectedCategoryId || null,
|
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') && (
|
{(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
|
||||||
<button
|
<button
|
||||||
onClick={handleTranscribe}
|
onClick={handleTranscribe}
|
||||||
disabled={isTranscribing}
|
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 ${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'}`}
|
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="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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSummarize}
|
onClick={handleSummarize}
|
||||||
disabled={isGeneratingSummary || !lesson.transcription}
|
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>
|
<span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
|
||||||
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div>
|
<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
|
<button
|
||||||
onClick={handleGenerateQuiz}
|
onClick={handleGenerateQuiz}
|
||||||
disabled={isGeneratingQuiz || !lesson.transcription}
|
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>
|
<span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
|
||||||
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
|
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
|
||||||
|
|||||||
@@ -11,19 +11,26 @@ interface FileUploadProps {
|
|||||||
|
|
||||||
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
|
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadingFileName, setUploadingFileName] = useState("");
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadingFileName(file.name);
|
||||||
try {
|
try {
|
||||||
const result = await cmsApi.uploadAsset(file);
|
const result = await cmsApi.uploadAsset(file, (pct) => {
|
||||||
|
setUploadProgress(pct);
|
||||||
|
});
|
||||||
onUploadComplete(result.url);
|
onUploadComplete(result.url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Upload failed. Please try again.");
|
alert("Upload failed. Please try again.");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setUploadingFileName("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,14 +61,47 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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
|
<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"
|
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}
|
onDragEnter={handleDrag}
|
||||||
onDragLeave={handleDrag}
|
onDragLeave={handleDrag}
|
||||||
onDragOver={handleDrag}
|
onDragOver={handleDrag}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => !isUploading && fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -69,15 +109,9 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
accept={accept}
|
accept={accept}
|
||||||
onChange={handleFileChange}
|
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">
|
<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>
|
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
|
||||||
</div>
|
</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-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>
|
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Native video, audio files supported</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentUrl && !isUploading && (
|
{currentUrl && !isUploading && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MediaPlayer from "../MediaPlayer";
|
import MediaPlayer from "../MediaPlayer";
|
||||||
import FileUpload from "../FileUpload";
|
import FileUpload from "../FileUpload";
|
||||||
|
import { getImageUrl } from "@/lib/api";
|
||||||
|
|
||||||
interface MediaBlockProps {
|
interface MediaBlockProps {
|
||||||
id: string;
|
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)
|
// Full URL for display (handles relative paths from server)
|
||||||
const displayUrl = url.startsWith("/") ? `http://localhost:3001${url}` : url;
|
const displayUrl = getImageUrl(url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
+29
-13
@@ -63,6 +63,7 @@ export interface Lesson {
|
|||||||
module_id: string;
|
module_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
|
content_url?: string;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
};
|
};
|
||||||
@@ -78,6 +79,7 @@ export interface Lesson {
|
|||||||
es?: string;
|
es?: string;
|
||||||
cues?: { start: number; end: number; text: string }[];
|
cues?: { start: number; end: number; text: string }[];
|
||||||
} | null;
|
} | null;
|
||||||
|
transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,26 +273,40 @@ export const cmsApi = {
|
|||||||
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
|
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
uploadAsset: (file: File): Promise<UploadResponse> => {
|
uploadAsset: (file: File, onProgress?: (pct: number) => void): Promise<UploadResponse> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', `${API_BASE_URL}/assets/upload`);
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
const selectedOrgId = getSelectedOrgId();
|
const selectedOrgId = getSelectedOrgId();
|
||||||
const headers: Record<string, string> = {
|
if (selectedOrgId) xhr.setRequestHeader('X-Organization-Id', selectedOrgId);
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
|
||||||
...(selectedOrgId ? { '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.
|
xhr.onload = () => {
|
||||||
// The browser will set it automatically with the correct boundary.
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
return fetch(`${API_BASE_URL}/assets/upload`, {
|
resolve(JSON.parse(xhr.responseText));
|
||||||
method: 'POST',
|
} else {
|
||||||
headers,
|
let msg = 'Upload failed';
|
||||||
body: formData,
|
try {
|
||||||
}).then(res => {
|
msg = JSON.parse(xhr.responseText).message || msg;
|
||||||
if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Upload failed')));
|
} catch { }
|
||||||
return res.json();
|
reject(new Error(msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Network error'));
|
||||||
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Organizations Branding
|
// Organizations Branding
|
||||||
|
|||||||
Reference in New Issue
Block a user