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

This commit is contained in:
2026-01-15 18:02:04 -03:00
parent daeda7e905
commit 663950aa0e
26 changed files with 933 additions and 302 deletions
+64 -26
View File
@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use bcrypt::{DEFAULT_COST, hash, verify};
@@ -251,11 +251,40 @@ pub async fn ingest_course(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Upsert Course
// 1. Upsert Organization
let org_id = payload.course.organization_id;
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
domain = EXCLUDED.domain,
logo_url = EXCLUDED.logo_url,
primary_color = EXCLUDED.primary_color,
secondary_color = EXCLUDED.secondary_color,
certificate_template = EXCLUDED.certificate_template,
updated_at = EXCLUDED.updated_at"
)
.bind(payload.organization.id)
.bind(&payload.organization.name)
.bind(&payload.organization.domain)
.bind(&payload.organization.logo_url)
.bind(&payload.organization.primary_color)
.bind(&payload.organization.secondary_color)
.bind(&payload.organization.certificate_template)
.bind(payload.organization.created_at)
.bind(payload.organization.updated_at)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to upsert organization during ingestion: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 2. Upsert Course
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
@@ -265,7 +294,8 @@ pub async fn ingest_course(
passing_percentage = EXCLUDED.passing_percentage,
certificate_template = EXCLUDED.certificate_template,
updated_at = EXCLUDED.updated_at,
organization_id = EXCLUDED.organization_id"
organization_id = EXCLUDED.organization_id,
pacing_mode = EXCLUDED.pacing_mode"
)
.bind(payload.course.id)
.bind(&payload.course.title)
@@ -277,6 +307,7 @@ pub async fn ingest_course(
.bind(&payload.course.certificate_template)
.bind(payload.course.updated_at)
.bind(org_id)
.bind(&payload.course.pacing_mode)
.execute(&mut *tx)
.await
.map_err(|e| {
@@ -333,8 +364,8 @@ pub async fn ingest_course(
for lesson in pub_module.lessons {
sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
)
.bind(lesson.id)
.bind(pub_module.module.id)
@@ -350,6 +381,10 @@ pub async fn ingest_course(
.bind(lesson.max_attempts)
.bind(lesson.allow_retry)
.bind(org_id)
.bind(&lesson.summary)
.bind(lesson.due_date)
.bind(&lesson.important_date_type)
.bind(&lesson.transcription_status)
.execute(&mut *tx)
.await
.map_err(|e| {
@@ -371,43 +406,49 @@ pub async fn get_course_outline(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
tracing::info!("get_course_outline: fetching course {}", id);
// 1. Fetch Course
let course =
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>(
"SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position",
"SELECT * FROM modules WHERE course_id = $1 ORDER BY position",
)
.bind(id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 3. Fetch Grading Categories
// 3. Fetch Organization
let organization = sqlx::query_as::<_, common::models::Organization>(
"SELECT * FROM organizations WHERE id = $1",
)
.bind(course.organization_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 4. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at",
)
.bind(id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 4. Fetch Lessons
// 5. Fetch Lessons
let mut pub_modules = Vec::new();
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>(
"SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position",
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
)
.bind(module.id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -417,6 +458,7 @@ pub async fn get_course_outline(
Ok(Json(common::models::PublishedCourse {
course,
organization,
grading_categories,
modules: pub_modules,
}))
@@ -427,10 +469,10 @@ pub async fn get_lesson_content(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
tracing::info!("get_lesson_content: fetching lesson {}", id);
let lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
@@ -444,10 +486,9 @@ pub async fn get_user_enrollments(
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
let enrollments = sqlx::query_as::<_, Enrollment>(
"SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2",
"SELECT * FROM enrollments WHERE user_id = $1",
)
.bind(user_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -491,10 +532,9 @@ pub async fn submit_lesson_score(
// 1. Get lesson attempt rules
let max_attempts: Option<Option<i32>> = sqlx::query_scalar(
"SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2",
"SELECT max_attempts FROM lessons WHERE id = $1",
)
.bind(payload.lesson_id)
.bind(org_ctx.id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -560,8 +600,7 @@ pub async fn submit_lesson_score(
.await;
// Detect course completion logic
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)")
.bind(org_ctx.id)
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)")
.bind(payload.course_id)
.fetch_one(&pool).await.unwrap_or(0);
@@ -675,11 +714,10 @@ pub async fn get_user_course_grades(
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
let grades = sqlx::query_as::<_, common::models::UserGrade>(
"SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3",
"SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2",
)
.bind(user_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;