feat: add organization branding support with database fields and API for logo upload and color customization.
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
-- Migration: Add Organization Branding Support
|
||||||
|
-- Adds fields for logo, colors, and certificate customization
|
||||||
|
-- Note: organizations table already exists from 20250116100000_add_multi_tenancy.sql
|
||||||
|
|
||||||
|
-- Add branding fields to organizations table (only if they don't exist)
|
||||||
|
ALTER TABLE organizations
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_color VARCHAR(7) DEFAULT '#3B82F6',
|
||||||
|
ADD COLUMN IF NOT EXISTS secondary_color VARCHAR(7) DEFAULT '#8B5CF6',
|
||||||
|
ADD COLUMN IF NOT EXISTS certificate_template TEXT;
|
||||||
|
|
||||||
|
-- Add index for performance on logo lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_organizations_logo ON organizations(logo_url) WHERE logo_url IS NOT NULL;
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON COLUMN organizations.logo_url IS 'URL path to organization logo (stored in /uploads/org-logos/)';
|
||||||
|
COMMENT ON COLUMN organizations.primary_color IS 'Primary brand color in hex format (#RRGGBB)';
|
||||||
|
COMMENT ON COLUMN organizations.secondary_color IS 'Secondary brand color in hex format (#RRGGBB)';
|
||||||
|
COMMENT ON COLUMN organizations.certificate_template IS 'Custom certificate template (future use)';
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Path, Query},
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics, Organization};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
use common::auth::create_jwt;
|
use common::auth::create_jwt;
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
|
use common::models::{
|
||||||
|
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
||||||
|
PublishedModule, User, UserResponse,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use serde_json::json;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
@@ -18,7 +21,8 @@ pub async fn publish_course(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
// 1. Fetch Course
|
// 1. Fetch Course
|
||||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
let course =
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -26,7 +30,8 @@ pub async fn publish_course(
|
|||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Fetch Modules
|
// 2. Fetch Modules
|
||||||
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
let modules =
|
||||||
|
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -36,7 +41,7 @@ pub async fn publish_course(
|
|||||||
|
|
||||||
// 3. Fetch Grading Categories
|
// 3. 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"
|
"SELECT * FROM grading_categories WHERE course_id = $1",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -45,16 +50,15 @@ pub async fn publish_course(
|
|||||||
|
|
||||||
// 4. Fetch Lessons for each Module
|
// 4. Fetch Lessons for each Module
|
||||||
for module in modules {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
let lessons = sqlx::query_as::<_, Lesson>(
|
||||||
|
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
|
||||||
|
)
|
||||||
.bind(module.id)
|
.bind(module.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
pub_modules.push(PublishedModule {
|
pub_modules.push(PublishedModule { module, lessons });
|
||||||
module,
|
|
||||||
lessons,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = PublishedCourse {
|
let payload = PublishedCourse {
|
||||||
@@ -66,7 +70,8 @@ pub async fn publish_course(
|
|||||||
// 4. Send to LMS
|
// 4. Send to LMS
|
||||||
// Using service name for Docker compatibility
|
// Using service name for Docker compatibility
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let res = client.post("http://lms-service:3002/ingest")
|
let res = client
|
||||||
|
.post("http://lms-service:3002/ingest")
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -109,7 +114,10 @@ pub async fn create_course(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let title = payload
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let instructor_id = claims.sub;
|
let instructor_id = claims.sub;
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
let course = sqlx::query_as::<_, Course>(
|
||||||
@@ -122,7 +130,15 @@ pub async fn create_course(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
log_action(&pool, instructor_id, "CREATE", "Course", course.id, json!({ "title": title })).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
instructor_id,
|
||||||
|
"CREATE",
|
||||||
|
"Course",
|
||||||
|
course.id,
|
||||||
|
json!({ "title": title }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(course))
|
Ok(Json(course))
|
||||||
}
|
}
|
||||||
@@ -147,7 +163,8 @@ pub async fn update_course(
|
|||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
// 1. Fetch course and check ownership/role
|
// 1. Fetch course and check ownership/role
|
||||||
let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
let existing =
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -159,14 +176,24 @@ pub async fn update_course(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update fields
|
// 2. Update fields
|
||||||
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(&existing.title);
|
let title = payload
|
||||||
let description = payload.get("description").and_then(|v| v.as_str()).unwrap_or(existing.description.as_deref().unwrap_or(""));
|
.get("title")
|
||||||
let passing_percentage = payload.get("passing_percentage").and_then(|v| v.as_i64()).unwrap_or(existing.passing_percentage as i64) as i32;
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&existing.title);
|
||||||
|
let description = payload
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(existing.description.as_deref().unwrap_or(""));
|
||||||
|
let passing_percentage = payload
|
||||||
|
.get("passing_percentage")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(existing.passing_percentage as i64) as i32;
|
||||||
|
|
||||||
// Check if certificate_template is in payload (even if null to unset?)
|
// Check if certificate_template is in payload (even if null to unset?)
|
||||||
// For simplicity: if provided as string, use it. If not provided, keep existing.
|
// For simplicity: if provided as string, use it. If not provided, keep existing.
|
||||||
// To unset, user can send empty string maybe?
|
// To unset, user can send empty string maybe?
|
||||||
let certificate_template = payload.get("certificate_template")
|
let certificate_template = payload
|
||||||
|
.get("certificate_template")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.or(existing.certificate_template);
|
.or(existing.certificate_template);
|
||||||
@@ -192,13 +219,22 @@ pub async fn create_module(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Module>, StatusCode> {
|
) -> Result<Json<Module>, StatusCode> {
|
||||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let title = payload
|
||||||
let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let course_id_str = payload
|
||||||
|
.get("course_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
let position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
let position = payload
|
||||||
|
.get("position")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
|
||||||
let module = sqlx::query_as::<_, Module>(
|
let module = sqlx::query_as::<_, Module>(
|
||||||
"INSERT INTO modules (course_id, title, position) VALUES ($1, $2, $3) RETURNING *"
|
"INSERT INTO modules (course_id, title, position) VALUES ($1, $2, $3) RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
@@ -207,7 +243,15 @@ pub async fn create_module(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "CREATE", "Module", module.id, json!({ "title": title, "course_id": course_id })).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"CREATE",
|
||||||
|
"Module",
|
||||||
|
module.id,
|
||||||
|
json!({ "title": title, "course_id": course_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(module))
|
Ok(Json(module))
|
||||||
}
|
}
|
||||||
@@ -217,19 +261,43 @@ pub async fn create_lesson(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let title = payload
|
||||||
let module_id_str = payload.get("module_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let module_id_str = payload
|
||||||
|
.get("module_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let module_id = Uuid::parse_str(module_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
let module_id = Uuid::parse_str(module_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
let content_type = payload.get("content_type").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let content_type = payload
|
||||||
|
.get("content_type")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let content_url = payload.get("content_url").and_then(|v| v.as_str());
|
let content_url = payload.get("content_url").and_then(|v| v.as_str());
|
||||||
let position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
let position = payload
|
||||||
|
.get("position")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
let transcription = payload.get("transcription").cloned();
|
let transcription = payload.get("transcription").cloned();
|
||||||
let metadata = payload.get("metadata").cloned();
|
let metadata = payload.get("metadata").cloned();
|
||||||
|
|
||||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false);
|
let is_graded = payload
|
||||||
let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok());
|
.get("is_graded")
|
||||||
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
|
.and_then(|v| v.as_bool())
|
||||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()).unwrap_or(true);
|
.unwrap_or(false);
|
||||||
|
let grading_category_id = payload
|
||||||
|
.get("grading_category_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
|
let max_attempts = payload
|
||||||
|
.get("max_attempts")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
let allow_retry = payload
|
||||||
|
.get("allow_retry")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
let lesson = sqlx::query_as::<_, Lesson>(
|
let lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
|
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
|
||||||
@@ -250,7 +318,15 @@ pub async fn create_lesson(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "CREATE", "Lesson", lesson.id, json!({ "title": title, "module_id": module_id })).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"CREATE",
|
||||||
|
"Lesson",
|
||||||
|
lesson.id,
|
||||||
|
json!({ "title": title, "module_id": module_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(lesson))
|
Ok(Json(lesson))
|
||||||
}
|
}
|
||||||
@@ -279,7 +355,7 @@ pub async fn process_transcription(
|
|||||||
|
|
||||||
// 3. Update lesson
|
// 3. Update lesson
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *"
|
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(mock_transcription)
|
.bind(mock_transcription)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -287,7 +363,15 @@ pub async fn process_transcription(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "TRANSCRIPTION_PROCESSED", "Lesson", id, json!({})).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"TRANSCRIPTION_PROCESSED",
|
||||||
|
"Lesson",
|
||||||
|
id,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(updated_lesson))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
@@ -312,16 +396,23 @@ pub async fn summarize_lesson(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 3. Update lesson
|
// 3. Update lesson
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
let updated_lesson =
|
||||||
"UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *"
|
sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *")
|
||||||
)
|
|
||||||
.bind(mock_summary)
|
.bind(mock_summary)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "SUMMARY_GENERATED", "Lesson", id, json!({})).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"SUMMARY_GENERATED",
|
||||||
|
"Lesson",
|
||||||
|
id,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(updated_lesson))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
@@ -390,9 +481,15 @@ pub async fn update_lesson(
|
|||||||
let title = payload.get("title").and_then(|t| t.as_str());
|
let title = payload.get("title").and_then(|t| t.as_str());
|
||||||
let content_type = payload.get("content_type").and_then(|t| t.as_str());
|
let content_type = payload.get("content_type").and_then(|t| t.as_str());
|
||||||
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
||||||
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
let position = payload
|
||||||
|
.get("position")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
|
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
|
||||||
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
|
let max_attempts = payload
|
||||||
|
.get("max_attempts")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
||||||
let metadata = payload.get("metadata");
|
let metadata = payload.get("metadata");
|
||||||
|
|
||||||
@@ -459,7 +556,7 @@ pub async fn create_grading_category(
|
|||||||
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *"
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(payload.course_id)
|
.bind(payload.course_id)
|
||||||
.bind(payload.name)
|
.bind(payload.name)
|
||||||
@@ -485,7 +582,7 @@ pub async fn delete_grading_category(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_action(
|
pub async fn log_action(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
action: &str,
|
action: &str,
|
||||||
@@ -510,7 +607,8 @@ pub async fn get_course(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
let course =
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -586,12 +684,28 @@ pub async fn upload_asset(
|
|||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
let mut mimetype = String::new();
|
let mut mimetype = String::new();
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
while let Some(field) =
|
||||||
|
multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e: axum::extract::multipart::MultipartError| {
|
||||||
|
(StatusCode::BAD_REQUEST, e.to_string())
|
||||||
|
})?
|
||||||
|
{
|
||||||
let name = field.name().unwrap_or_default().to_string();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
if name == "file" {
|
if name == "file" {
|
||||||
filename = field.file_name().unwrap_or("unnamed").to_string();
|
filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||||
mimetype = field.content_type().unwrap_or("application/octet-stream").to_string();
|
mimetype = field
|
||||||
data = field.bytes().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?.to_vec();
|
.content_type()
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
data = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e: axum::extract::multipart::MultipartError| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
|
})?
|
||||||
|
.to_vec();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,13 +723,20 @@ pub async fn upload_asset(
|
|||||||
let storage_path = format!("uploads/{}", storage_filename);
|
let storage_path = format!("uploads/{}", storage_filename);
|
||||||
|
|
||||||
// Ensure uploads directory exists
|
// Ensure uploads directory exists
|
||||||
tokio::fs::create_dir_all("uploads").await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
tokio::fs::create_dir_all("uploads")
|
||||||
|
.await
|
||||||
|
.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Write file
|
// Write file
|
||||||
tokio::fs::write(&storage_path, data).await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
tokio::fs::write(&storage_path, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Record in DB
|
// Record in DB
|
||||||
let size_bytes = tokio::fs::metadata(&storage_path).await.map(|m| m.len() as i64).unwrap_or(0);
|
let size_bytes = tokio::fs::metadata(&storage_path)
|
||||||
|
.await
|
||||||
|
.map(|m| m.len() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)"
|
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)"
|
||||||
@@ -655,7 +776,14 @@ pub async fn register(
|
|||||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("User").to_string());
|
let full_name = payload.full_name.unwrap_or_else(|| {
|
||||||
|
payload
|
||||||
|
.email
|
||||||
|
.split('@')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("User")
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
let role = payload.role.unwrap_or_else(|| "instructor".to_string());
|
let role = payload.role.unwrap_or_else(|| "instructor".to_string());
|
||||||
|
|
||||||
// Find or create organization based on email domain
|
// Find or create organization based on email domain
|
||||||
@@ -664,7 +792,10 @@ pub async fn register(
|
|||||||
parts.get(1).unwrap_or(&"default.com").to_string()
|
parts.get(1).unwrap_or(&"default.com").to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let organization = sqlx::query_as::<_, Organization>(
|
let organization = sqlx::query_as::<_, Organization>(
|
||||||
"INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *"
|
"INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *"
|
||||||
@@ -692,10 +823,16 @@ pub async fn register(
|
|||||||
(StatusCode::CONFLICT, format!("User already exists or DB error: {}", e))
|
(StatusCode::CONFLICT, format!("User already exists or DB error: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let token = create_jwt(user.id, user.organization_id, &user.role)
|
let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| {
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"JWT generation failed".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
user: UserResponse {
|
user: UserResponse {
|
||||||
@@ -718,12 +855,21 @@ pub async fn login(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||||
|
|
||||||
if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? {
|
if !verify(payload.password, &user.password_hash).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Verification failed".into(),
|
||||||
|
)
|
||||||
|
})? {
|
||||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = create_jwt(user.id, user.organization_id, &user.role)
|
let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| {
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"JWT generation failed".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
user: UserResponse {
|
user: UserResponse {
|
||||||
@@ -742,7 +888,8 @@ pub async fn get_course_analytics(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
||||||
// 1. Fetch Course to check ownership
|
// 1. Fetch Course to check ownership
|
||||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
let course =
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -751,21 +898,30 @@ pub async fn get_course_analytics(
|
|||||||
|
|
||||||
// 2. Enforce RBAC
|
// 2. Enforce RBAC
|
||||||
if claims.role != "admin" && course.instructor_id != claims.sub {
|
if claims.role != "admin" && course.instructor_id != claims.sub {
|
||||||
return Err((StatusCode::FORBIDDEN, "You do not have permission to view stats for a course you don't own".into()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You do not have permission to view stats for a course you don't own".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fetch from LMS
|
// 4. Fetch from LMS
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let res = client.get(format!("http://lms-service:3002/courses/{}/analytics", id))
|
let res = client
|
||||||
|
.get(format!("http://lms-service:3002/courses/{}/analytics", id))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
if !res.status().is_success() {
|
||||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch analytics from LMS".into()));
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to fetch analytics from LMS".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let analytics = res.json::<CourseAnalytics>().await
|
let analytics = res
|
||||||
|
.json::<CourseAnalytics>()
|
||||||
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(analytics))
|
Ok(Json(analytics))
|
||||||
@@ -785,7 +941,10 @@ pub async fn get_audit_logs(
|
|||||||
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (StatusCode, String)> {
|
||||||
// 1. RBAC check
|
// 1. RBAC check
|
||||||
if claims.role != "admin" {
|
if claims.role != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins can view audit logs".into()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins can view audit logs".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Query (filtered by organization)
|
// 2. Query (filtered by organization)
|
||||||
@@ -845,7 +1004,8 @@ pub async fn get_course_outline(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<CourseWithOutline>, StatusCode> {
|
) -> Result<Json<CourseWithOutline>, StatusCode> {
|
||||||
// 1. Fetch Course
|
// 1. Fetch Course
|
||||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
let course =
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -853,7 +1013,8 @@ pub async fn get_course_outline(
|
|||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Fetch Modules
|
// 2. Fetch Modules
|
||||||
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
let modules =
|
||||||
|
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -863,16 +1024,15 @@ pub async fn get_course_outline(
|
|||||||
|
|
||||||
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
|
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
|
||||||
for module in modules {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
let lessons = sqlx::query_as::<_, Lesson>(
|
||||||
|
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
|
||||||
|
)
|
||||||
.bind(module.id)
|
.bind(module.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
modules_with_lessons.push(ModuleWithLessons {
|
modules_with_lessons.push(ModuleWithLessons { module, lessons });
|
||||||
module,
|
|
||||||
lessons,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(CourseWithOutline {
|
Ok(Json(CourseWithOutline {
|
||||||
@@ -888,13 +1048,16 @@ pub async fn update_module(
|
|||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Module>, StatusCode> {
|
) -> Result<Json<Module>, StatusCode> {
|
||||||
let title = payload.get("title").and_then(|t| t.as_str());
|
let title = payload.get("title").and_then(|t| t.as_str());
|
||||||
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
let position = payload
|
||||||
|
.get("position")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
|
||||||
let updated_module = sqlx::query_as::<_, Module>(
|
let updated_module = sqlx::query_as::<_, Module>(
|
||||||
"UPDATE modules
|
"UPDATE modules
|
||||||
SET title = COALESCE($1, title),
|
SET title = COALESCE($1, title),
|
||||||
position = COALESCE($2, position)
|
position = COALESCE($2, position)
|
||||||
WHERE id = $3 RETURNING *"
|
WHERE id = $3 RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(position)
|
.bind(position)
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Organization Branding Handlers
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::models::Organization;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::handlers::log_action;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct BrandingPayload {
|
||||||
|
pub primary_color: Option<String>,
|
||||||
|
pub secondary_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BrandingResponse {
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
pub primary_color: String,
|
||||||
|
pub secondary_color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload organization logo
|
||||||
|
pub async fn upload_organization_logo(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(org_id): Path<Uuid>,
|
||||||
|
mut multipart: axum::extract::Multipart,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// Only admins can upload logos
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify organization exists and user has access
|
||||||
|
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
|
||||||
|
|
||||||
|
// Process multipart form
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if name == "file" {
|
||||||
|
let filename = field
|
||||||
|
.file_name()
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
let ext = filename.split('.').last().unwrap_or("");
|
||||||
|
if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid file type. Only PNG, JPG, and SVG allowed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = field.bytes().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to read file: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Validate file size (max 2MB)
|
||||||
|
if data.len() > 2 * 1024 * 1024 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"File too large. Maximum 2MB allowed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
std::fs::create_dir_all("uploads/org-logos").map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to create directory: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
let unique_filename = format!("{}_{}.{}", org_id, uuid::Uuid::new_v4(), ext);
|
||||||
|
let filepath = format!("uploads/org-logos/{}", unique_filename);
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
std::fs::write(&filepath, &data).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to save file: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Update organization in database
|
||||||
|
let logo_url = format!("/{}", filepath);
|
||||||
|
sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2")
|
||||||
|
.bind(&logo_url)
|
||||||
|
.bind(org_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"UPDATE_LOGO",
|
||||||
|
"Organization",
|
||||||
|
org_id,
|
||||||
|
json!({"logo_url": &logo_url}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"message": "Logo uploaded successfully"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update organization branding colors
|
||||||
|
pub async fn update_organization_branding(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(org_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<BrandingPayload>,
|
||||||
|
) -> Result<Json<Organization>, (StatusCode, String)> {
|
||||||
|
// Only admins can update branding
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hex color format
|
||||||
|
let validate_color = |color: &str| -> bool {
|
||||||
|
color.len() == 7
|
||||||
|
&& color.starts_with('#')
|
||||||
|
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref primary) = payload.primary_color {
|
||||||
|
if !validate_color(primary) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid primary_color format. Use #RRGGBB".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref secondary) = payload.secondary_color {
|
||||||
|
if !validate_color(secondary) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid secondary_color format. Use #RRGGBB".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update organization
|
||||||
|
let org = sqlx::query_as::<_, Organization>(
|
||||||
|
"UPDATE organizations
|
||||||
|
SET primary_color = COALESCE($1, primary_color),
|
||||||
|
secondary_color = COALESCE($2, secondary_color),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(&payload.primary_color)
|
||||||
|
.bind(&payload.secondary_color)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.sub,
|
||||||
|
"UPDATE_BRANDING",
|
||||||
|
"Organization",
|
||||||
|
org_id,
|
||||||
|
json!(payload),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(org))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get organization branding (public endpoint)
|
||||||
|
pub async fn get_organization_branding(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(org_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<BrandingResponse>, StatusCode> {
|
||||||
|
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Json(BrandingResponse {
|
||||||
|
logo_url: org.logo_url,
|
||||||
|
primary_color: org.primary_color.unwrap_or_else(|| "#3B82F6".to_string()),
|
||||||
|
secondary_color: org.secondary_color.unwrap_or_else(|| "#8B5CF6".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod handlers_branding;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post, delete},
|
Router, middleware,
|
||||||
Router,
|
routing::{delete, get, post},
|
||||||
middleware,
|
|
||||||
};
|
};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
use sqlx::postgres::PgPoolOptions;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -36,30 +36,68 @@ async fn main() {
|
|||||||
|
|
||||||
// Rutas protegidas que requieren autenticación y contexto de organización
|
// Rutas protegidas que requieren autenticación y contexto de organización
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
|
.route(
|
||||||
.route("/courses/{id}", get(handlers::get_course).put(handlers::update_course))
|
"/courses",
|
||||||
|
get(handlers::get_courses).post(handlers::create_course),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/courses/{id}",
|
||||||
|
get(handlers::get_course).put(handlers::update_course),
|
||||||
|
)
|
||||||
.route("/courses/{id}/publish", post(handlers::publish_course))
|
.route("/courses/{id}/publish", post(handlers::publish_course))
|
||||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
.route(
|
||||||
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
"/courses/{id}/analytics",
|
||||||
|
get(handlers::get_course_analytics),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/modules",
|
||||||
|
get(handlers::get_modules).post(handlers::create_module),
|
||||||
|
)
|
||||||
.route("/modules/{id}", axum::routing::put(handlers::update_module))
|
.route("/modules/{id}", axum::routing::put(handlers::update_module))
|
||||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
.route(
|
||||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
"/lessons",
|
||||||
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
get(handlers::get_lessons).post(handlers::create_lesson),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/lessons/{id}",
|
||||||
|
get(handlers::get_lesson).put(handlers::update_lesson),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/lessons/{id}/transcribe",
|
||||||
|
post(handlers::process_transcription),
|
||||||
|
)
|
||||||
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
||||||
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
||||||
.route("/grading", post(handlers::create_grading_category))
|
.route("/grading", post(handlers::create_grading_category))
|
||||||
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
||||||
.route("/courses/{id}/grading", get(handlers::get_grading_categories))
|
.route(
|
||||||
|
"/courses/{id}/grading",
|
||||||
|
get(handlers::get_grading_categories),
|
||||||
|
)
|
||||||
.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))
|
||||||
.route("/organization", get(handlers::get_organization))
|
.route("/organization", get(handlers::get_organization))
|
||||||
.route_layer(middleware::from_fn(common::middleware::org_extractor_middleware));
|
.route(
|
||||||
|
"/organizations/:id/logo",
|
||||||
|
post(handlers_branding::upload_organization_logo),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/organizations/:id/branding",
|
||||||
|
axum::routing::put(handlers_branding::update_organization_branding),
|
||||||
|
)
|
||||||
|
.route_layer(middleware::from_fn(
|
||||||
|
common::middleware::org_extractor_middleware,
|
||||||
|
));
|
||||||
|
|
||||||
// Rutas públicas que no requieren autenticación
|
// Rutas públicas que no requieren autenticación
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/auth/register", post(handlers::register))
|
.route("/auth/register", post(handlers::register))
|
||||||
.route("/auth/login", post(handlers::login))
|
.route("/auth/login", post(handlers::login))
|
||||||
|
.route(
|
||||||
|
"/organizations/:id/branding",
|
||||||
|
get(handlers_branding::get_organization_branding),
|
||||||
|
)
|
||||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Course {
|
pub struct Course {
|
||||||
@@ -133,6 +133,10 @@ pub struct UserResponse {
|
|||||||
pub struct Organization {
|
pub struct Organization {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
pub primary_color: Option<String>,
|
||||||
|
pub secondary_color: Option<String>,
|
||||||
|
pub certificate_template: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user