feat: Introduce multi-tenancy support with organization-specific data, add interactive transcript functionality, and enhance lesson/course schemas.
This commit is contained in:
@@ -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