feat: Introduce multi-tenancy support with organization-specific data, add interactive transcript functionality, and enhance lesson/course schemas.
This commit is contained in:
@@ -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
|
||||
.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 *",
|
||||
)
|
||||
.bind(transcription)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database update failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
// 5. Update lesson
|
||||
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
|
||||
.bind(transcription)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("Final database update failed: {}", e))?;
|
||||
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"TRANSCRIPTION_PROCESSED",
|
||||
"Lesson",
|
||||
id,
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(updated_lesson))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn summarize_lesson(
|
||||
@@ -620,6 +664,7 @@ pub async fn summarize_lesson(
|
||||
State(pool): State<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))
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user