feat: Implement multi-tenancy with organization ID in LMS tables and middleware, refactor web API calls, and update analytics and gamification features."
This commit is contained in:
@@ -88,7 +88,7 @@ pub async fn publish_course(
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
|
||||
log_action(&pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
|
||||
|
||||
// 5. Trigger Webhook
|
||||
let webhook_service = WebhookService::new(pool.clone());
|
||||
@@ -484,13 +484,15 @@ pub async fn create_lesson(
|
||||
}
|
||||
|
||||
pub async fn process_transcription(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -600,6 +602,7 @@ pub async fn process_transcription(
|
||||
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"TRANSCRIPTION_PROCESSED",
|
||||
"Lesson",
|
||||
@@ -612,13 +615,15 @@ pub async fn process_transcription(
|
||||
}
|
||||
|
||||
pub async fn summarize_lesson(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
@@ -708,6 +713,7 @@ pub async fn summarize_lesson(
|
||||
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"SUMMARY_GENERATED",
|
||||
"Lesson",
|
||||
@@ -720,13 +726,15 @@ pub async fn summarize_lesson(
|
||||
}
|
||||
|
||||
pub async fn generate_quiz(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
// 1. Fetch lesson
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
@@ -816,7 +824,16 @@ pub async fn generate_quiz(
|
||||
.cloned()
|
||||
.unwrap_or(json!([]));
|
||||
|
||||
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"QUIZ_GENERATED",
|
||||
"Lesson",
|
||||
id,
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(quiz_blocks))
|
||||
}
|
||||
@@ -985,11 +1002,13 @@ pub async fn create_grading_category(
|
||||
}
|
||||
|
||||
pub async fn delete_grading_category(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
sqlx::query("DELETE FROM grading_categories WHERE id = $1")
|
||||
sqlx::query("DELETE FROM grading_categories WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@@ -999,6 +1018,7 @@ pub async fn delete_grading_category(
|
||||
|
||||
pub async fn log_action(
|
||||
pool: &PgPool,
|
||||
organization_id: Uuid,
|
||||
user_id: Uuid,
|
||||
action: &str,
|
||||
entity_type: &str,
|
||||
@@ -1006,9 +1026,10 @@ pub async fn log_action(
|
||||
changes: serde_json::Value,
|
||||
) {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO audit_logs (user_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5)"
|
||||
"INSERT INTO audit_logs (user_id, organization_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(organization_id)
|
||||
.bind(action)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
@@ -1796,6 +1817,7 @@ pub async fn delete_lesson(
|
||||
|
||||
// User Management
|
||||
pub async fn get_all_users(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
|
||||
@@ -1804,8 +1826,9 @@ pub async fn get_all_users(
|
||||
}
|
||||
|
||||
let users = sqlx::query_as::<_, UserResponse>(
|
||||
"SELECT id, email, full_name, role, organization_id FROM users",
|
||||
"SELECT id, email, full_name, role, organization_id FROM users WHERE organization_id = $1",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1817,13 +1840,14 @@ pub async fn get_all_users(
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if claims.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||
if claims.role != "admin" && claims.sub != id {
|
||||
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||
}
|
||||
|
||||
let role = payload.get("role").and_then(|r| r.as_str());
|
||||
@@ -1833,16 +1857,17 @@ pub async fn update_user(
|
||||
.and_then(|o| Uuid::parse_str(o).ok());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3"
|
||||
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3 AND organization_id = $4"
|
||||
)
|
||||
.bind(role)
|
||||
.bind(organization_id)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
log_action(&pool, claims.sub, "UPDATE_USER", "User", id, payload).await;
|
||||
log_action(&pool, org_ctx.id, claims.sub, "UPDATE_USER", "User", id, payload).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -1915,7 +1940,7 @@ pub async fn get_webhooks(
|
||||
}
|
||||
|
||||
let webhooks = sqlx::query_as::<_, common::models::Webhook>(
|
||||
"SELECT * FROM webhooks WHERE organization_id = ORDER BY created_at DESC",
|
||||
"SELECT * FROM webhooks WHERE organization_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
@@ -1938,7 +1963,7 @@ pub async fn create_webhook(
|
||||
let webhook = sqlx::query_as::<_, common::models::Webhook>(
|
||||
r#"
|
||||
INSERT INTO webhooks (organization_id, url, events, secret)
|
||||
VALUES (, , , )
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
@@ -1952,6 +1977,7 @@ pub async fn create_webhook(
|
||||
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"CREATE_WEBHOOK",
|
||||
"Webhook",
|
||||
@@ -1973,7 +1999,7 @@ pub async fn delete_webhook(
|
||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM webhooks WHERE id = AND organization_id = ")
|
||||
let result = sqlx::query("DELETE FROM webhooks WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
@@ -1986,6 +2012,7 @@ pub async fn delete_webhook(
|
||||
|
||||
log_action(
|
||||
&pool,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
"DELETE_WEBHOOK",
|
||||
"Webhook",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Migration: Update Analytics Functions for Multi-Tenancy
|
||||
-- Scope: fn_get_cohort_analytics, fn_get_retention_data
|
||||
|
||||
-- 1. Update Course Cohort Analytics to include p_organization_id
|
||||
CREATE OR REPLACE FUNCTION fn_get_cohort_analytics(p_course_id UUID, p_organization_id UUID)
|
||||
RETURNS TABLE (
|
||||
period TEXT,
|
||||
student_count BIGINT,
|
||||
completion_rate FLOAT4
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH cohort_students AS (
|
||||
SELECT
|
||||
user_id,
|
||||
TO_CHAR(enrolled_at, 'YYYY-MM') as v_period
|
||||
FROM enrollments
|
||||
WHERE course_id = p_course_id AND organization_id = p_organization_id
|
||||
),
|
||||
course_lesson_count AS (
|
||||
SELECT COUNT(*)::float4 as total_lessons
|
||||
FROM lessons
|
||||
WHERE module_id IN (SELECT id FROM modules WHERE course_id = p_course_id AND organization_id = p_organization_id)
|
||||
AND organization_id = p_organization_id
|
||||
)
|
||||
SELECT
|
||||
cs.v_period as period,
|
||||
COUNT(DISTINCT cs.user_id) as student_count,
|
||||
COALESCE(AVG(
|
||||
(SELECT COUNT(DISTINCT lesson_id)::float4 FROM user_grades WHERE user_id = cs.user_id AND course_id = p_course_id AND organization_id = p_organization_id) /
|
||||
NULLIF((SELECT total_lessons FROM course_lesson_count), 0)
|
||||
), 0)::float4 as completion_rate
|
||||
FROM cohort_students cs
|
||||
GROUP BY cs.v_period
|
||||
ORDER BY cs.v_period DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 2. Update Retention Data Function to include p_organization_id
|
||||
CREATE OR REPLACE FUNCTION fn_get_retention_data(p_course_id UUID, p_organization_id UUID)
|
||||
RETURNS TABLE (
|
||||
lesson_id UUID,
|
||||
lesson_title VARCHAR,
|
||||
student_count BIGINT
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
l.id as lesson_id,
|
||||
l.title as lesson_title,
|
||||
COUNT(DISTINCT ug.user_id) as student_count
|
||||
FROM lessons l
|
||||
LEFT JOIN user_grades ug ON l.id = ug.lesson_id AND ug.organization_id = p_organization_id
|
||||
WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = p_course_id AND organization_id = p_organization_id)
|
||||
AND l.organization_id = p_organization_id
|
||||
GROUP BY l.id, l.title, l.position
|
||||
ORDER BY l.position;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Migration: Add organization_id to remaining content tables (LMS)
|
||||
-- Tables: modules, lessons, grading_categories, user_grades, user_badges, points_log
|
||||
|
||||
-- 1. Add organization_id to modules
|
||||
ALTER TABLE modules ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||
ALTER TABLE modules ADD CONSTRAINT fk_module_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ALTER TABLE modules ALTER COLUMN organization_id DROP DEFAULT;
|
||||
|
||||
-- 2. Add organization_id to lessons
|
||||
ALTER TABLE lessons ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||
ALTER TABLE lessons ADD CONSTRAINT fk_lesson_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ALTER TABLE lessons ALTER COLUMN organization_id DROP DEFAULT;
|
||||
|
||||
-- 3. Add organization_id to grading_categories
|
||||
ALTER TABLE grading_categories ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||
ALTER TABLE grading_categories ADD CONSTRAINT fk_grading_category_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ALTER TABLE grading_categories ALTER COLUMN organization_id DROP DEFAULT;
|
||||
|
||||
-- 4. Add organization_id to user_grades
|
||||
ALTER TABLE user_grades ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||
ALTER TABLE user_grades ADD CONSTRAINT fk_user_grade_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ALTER TABLE user_grades ALTER COLUMN organization_id DROP DEFAULT;
|
||||
|
||||
-- 5. Add organization_id to user_badges
|
||||
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;
|
||||
@@ -215,13 +215,29 @@ pub async fn login(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CatalogQuery {
|
||||
pub organization_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn get_course_catalog(
|
||||
State(pool): State<PgPool>,
|
||||
Query(query): Query<CatalogQuery>,
|
||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let courses = match query.organization_id {
|
||||
Some(org_id) => {
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
||||
.bind(org_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(courses))
|
||||
}
|
||||
@@ -543,14 +559,16 @@ pub async fn submit_lesson_score(
|
||||
)
|
||||
.await;
|
||||
|
||||
// TODO: Detect course completion logic
|
||||
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)")
|
||||
// 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)
|
||||
.bind(payload.course_id)
|
||||
.fetch_one(&pool).await.unwrap_or(0);
|
||||
|
||||
let completed_lessons: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2",
|
||||
"SELECT COUNT(*) FROM user_grades WHERE organization_id = $1 AND user_id = $2 AND course_id = $3",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(payload.user_id)
|
||||
.bind(payload.course_id)
|
||||
.fetch_one(&pool)
|
||||
@@ -590,12 +608,13 @@ pub struct BadgeResponse {
|
||||
}
|
||||
|
||||
pub async fn get_user_gamification(
|
||||
Org(_org_ctx): Org,
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<GamificationStatus>, StatusCode> {
|
||||
let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1")
|
||||
let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1 AND organization_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -604,9 +623,10 @@ pub async fn get_user_gamification(
|
||||
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
|
||||
FROM user_badges ub
|
||||
JOIN badges b ON ub.badge_id = b.id
|
||||
WHERE ub.user_id = $1",
|
||||
WHERE ub.user_id = $1 AND ub.organization_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -732,15 +752,16 @@ pub async fn get_course_analytics(
|
||||
}
|
||||
|
||||
pub async fn get_advanced_analytics(
|
||||
Org(_org_ctx): Org,
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<common::models::AdvancedAnalytics>, StatusCode> {
|
||||
// 1. Cohort Analysis using DB function
|
||||
let cohort_data = sqlx::query_as::<_, common::models::CohortData>(
|
||||
"SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1)",
|
||||
"SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1, $2)",
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -750,9 +771,10 @@ pub async fn get_advanced_analytics(
|
||||
|
||||
// 2. Retention Analysis using DB function
|
||||
let retention_data = sqlx::query_as::<_, common::models::RetentionData>(
|
||||
"SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1)",
|
||||
"SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1, $2)",
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
Reference in New Issue
Block a user