From 1a2b9e473ca0a2477f38b12c81faf03170592268 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Sun, 28 Dec 2025 22:08:03 -0300 Subject: [PATCH] feat: add organization branding support with database fields and API for logo upload and color customization. --- ...231229000001_add_organization_branding.sql | 19 + services/cms-service/src/handlers.rs | 417 ++++++++++++------ services/cms-service/src/handlers_branding.rs | 227 ++++++++++ services/cms-service/src/main.rs | 68 ++- shared/common/src/models.rs | 12 +- 5 files changed, 597 insertions(+), 146 deletions(-) create mode 100644 services/cms-service/migrations/20231229000001_add_organization_branding.sql create mode 100644 services/cms-service/src/handlers_branding.rs diff --git a/services/cms-service/migrations/20231229000001_add_organization_branding.sql b/services/cms-service/migrations/20231229000001_add_organization_branding.sql new file mode 100644 index 0000000..f879c2e --- /dev/null +++ b/services/cms-service/migrations/20231229000001_add_organization_branding.sql @@ -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)'; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index e9fc261..a635084 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1,16 +1,19 @@ use axum::{ - extract::{State, Path, Query}, - http::StatusCode, 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::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 uuid::Uuid; -use serde_json::json; -use serde::{Deserialize, Serialize}; -use bcrypt::{hash, verify, DEFAULT_COST}; pub async fn publish_course( Org(org_ctx): Org, @@ -18,25 +21,27 @@ pub async fn publish_course( Path(id): Path, ) -> Result { // 1. Fetch Course - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .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 ORDER BY position") - .bind(id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let modules = + sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut pub_modules = Vec::new(); // 3. Fetch Grading Categories 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) .fetch_all(&pool) @@ -45,16 +50,15 @@ pub async fn publish_course( // 4. 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") - .bind(module.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let lessons = sqlx::query_as::<_, Lesson>( + "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", + ) + .bind(module.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - pub_modules.push(PublishedModule { - module, - lessons, - }); + pub_modules.push(PublishedModule { module, lessons }); } let payload = PublishedCourse { @@ -66,7 +70,8 @@ pub async fn publish_course( // 4. Send to LMS // Using service name for Docker compatibility 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) .send() .await @@ -109,7 +114,10 @@ pub async fn create_course( State(pool): State, Json(payload): Json, ) -> Result, 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 course = sqlx::query_as::<_, Course>( @@ -122,7 +130,15 @@ pub async fn create_course( .await .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)) } @@ -147,26 +163,37 @@ pub async fn update_course( Json(payload): Json, ) -> Result, (StatusCode, String)> { // 1. Fetch course and check ownership/role - let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; + let existing = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; if claims.role != "admin" && existing.instructor_id != claims.sub { return Err((StatusCode::FORBIDDEN, "Not authorized".into())); } // 2. Update fields - let title = payload.get("title").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; - + let title = payload + .get("title") + .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?) // For simplicity: if provided as string, use it. If not provided, keep existing. - // To unset, user can send empty string maybe? - let certificate_template = payload.get("certificate_template") + // To unset, user can send empty string maybe? + let certificate_template = payload + .get("certificate_template") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or(existing.certificate_template); @@ -192,13 +219,22 @@ pub async fn create_module( State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { - let title = payload.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 title = payload + .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 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>( - "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(title) @@ -207,7 +243,15 @@ pub async fn create_module( .await .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)) } @@ -217,19 +261,43 @@ pub async fn create_lesson( State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { - let title = payload.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 title = payload + .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 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 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 metadata = payload.get("metadata").cloned(); - let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).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 is_graded = payload + .get("is_graded") + .and_then(|v| v.as_bool()) + .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>( "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 .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)) } @@ -279,7 +355,7 @@ pub async fn process_transcription( // 3. Update 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(id) @@ -287,7 +363,15 @@ pub async fn process_transcription( .await .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)) } @@ -312,16 +396,23 @@ pub async fn summarize_lesson( ); // 3. Update lesson - let updated_lesson = sqlx::query_as::<_, Lesson>( - "UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *" - ) - .bind(mock_summary) - .bind(id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let updated_lesson = + sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *") + .bind(mock_summary) + .bind(id) + .fetch_one(&pool) + .await + .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)) } @@ -390,12 +481,18 @@ pub async fn update_lesson( 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_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 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 metadata = payload.get("metadata"); - + let updated_lesson = sqlx::query_as::<_, Lesson>( "UPDATE lessons SET title = COALESCE($1, title), @@ -459,7 +556,7 @@ pub async fn create_grading_category( let category = sqlx::query_as::<_, common::models::GradingCategory>( "INSERT INTO grading_categories (course_id, name, weight, drop_count) VALUES ($1, $2, $3, $4) - RETURNING *" + RETURNING *", ) .bind(payload.course_id) .bind(payload.name) @@ -485,7 +582,7 @@ pub async fn delete_grading_category( Ok(StatusCode::OK) } -async fn log_action( +pub async fn log_action( pool: &PgPool, user_id: Uuid, action: &str, @@ -510,12 +607,13 @@ pub async fn get_course( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(course)) } @@ -586,12 +684,28 @@ pub async fn upload_asset( let mut data = Vec::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(); if name == "file" { filename = field.file_name().unwrap_or("unnamed").to_string(); - mimetype = field.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(); + mimetype = field + .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(); } } @@ -604,19 +718,26 @@ pub async fn upload_asset( .extension() .and_then(|s| s.to_str()) .unwrap_or(""); - + let storage_filename = format!("{}.{}", asset_id, extension); let storage_path = format!("uploads/{}", storage_filename); // 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 - 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 - 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( "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) .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()); // 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() }); - 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>( "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)) })?; - 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) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; + let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "JWT generation failed".into(), + ) + })?; Ok(Json(AuthResponse { user: UserResponse { @@ -718,12 +855,21 @@ pub async fn login( .await .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())); } - let token = create_jwt(user.id, user.organization_id, &user.role) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; + let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "JWT generation failed".into(), + ) + })?; Ok(Json(AuthResponse { user: UserResponse { @@ -742,30 +888,40 @@ pub async fn get_course_analytics( Path(id): Path, ) -> Result, (StatusCode, String)> { // 1. Fetch Course to check ownership - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; // 2. Enforce RBAC 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 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() .await .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; 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::().await + let analytics = res + .json::() + .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(analytics)) @@ -785,7 +941,10 @@ pub async fn get_audit_logs( ) -> Result>, (StatusCode, String)> { // 1. RBAC check 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) @@ -845,34 +1004,35 @@ pub async fn get_course_outline( Path(id): Path, ) -> Result, StatusCode> { // 1. Fetch Course - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .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 ORDER BY position") - .bind(id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let modules = + sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut modules_with_lessons = Vec::new(); // 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale) for module in modules { - let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position") - .bind(module.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let lessons = sqlx::query_as::<_, Lesson>( + "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", + ) + .bind(module.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - modules_with_lessons.push(ModuleWithLessons { - module, - lessons, - }); + modules_with_lessons.push(ModuleWithLessons { module, lessons }); } Ok(Json(CourseWithOutline { @@ -888,13 +1048,16 @@ pub async fn update_module( Json(payload): Json, ) -> Result, StatusCode> { 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>( "UPDATE modules SET title = COALESCE($1, title), position = COALESCE($2, position) - WHERE id = $3 RETURNING *" + WHERE id = $3 RETURNING *", ) .bind(title) .bind(position) diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs new file mode 100644 index 0000000..0d72878 --- /dev/null +++ b/services/cms-service/src/handlers_branding.rs @@ -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, + pub secondary_color: Option, +} + +#[derive(Serialize)] +pub struct BrandingResponse { + pub logo_url: Option, + pub primary_color: String, + pub secondary_color: String, +} + +// Upload organization logo +pub async fn upload_organization_logo( + claims: common::auth::Claims, + State(pool): State, + Path(org_id): Path, + mut multipart: axum::extract::Multipart, +) -> Result, (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, + Path(org_id): Path, + Json(payload): Json, +) -> Result, (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, + Path(org_id): Path, +) -> Result, 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()), + })) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 29a51be..a23545c 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -1,15 +1,15 @@ mod handlers; +mod handlers_branding; use axum::{ - routing::{get, post, delete}, - Router, - middleware, + Router, middleware, + routing::{delete, get, post}, }; -use tower_http::cors::{Any, CorsLayer}; -use sqlx::postgres::PgPoolOptions; -use std::net::SocketAddr; use dotenvy::dotenv; +use sqlx::postgres::PgPoolOptions; use std::env; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; #[tokio::main] async fn main() { @@ -36,30 +36,68 @@ async fn main() { // Rutas protegidas que requieren autenticación y contexto de organización let protected_routes = Router::new() - .route("/courses", get(handlers::get_courses).post(handlers::create_course)) - .route("/courses/{id}", get(handlers::get_course).put(handlers::update_course)) + .route( + "/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}/outline", get(handlers::get_course_outline)) - .route("/courses/{id}/analytics", get(handlers::get_course_analytics)) - .route("/modules", get(handlers::get_modules).post(handlers::create_module)) + .route( + "/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("/lessons", 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", + 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}/generate-quiz", post(handlers::generate_quiz)) .route("/grading", post(handlers::create_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("/assets/upload", post(handlers::upload_asset)) .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 let public_routes = Router::new() .route("/auth/register", post(handlers::register)) .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")) .merge(protected_routes) .layer(cors) diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 50eaa03..fba272d 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use chrono::{DateTime, Utc}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Course { @@ -31,7 +31,7 @@ pub struct Lesson { pub id: Uuid, pub module_id: Uuid, pub title: String, - pub content_type: String, + pub content_type: String, pub content_url: Option, pub summary: Option, pub transcription: Option, @@ -72,7 +72,7 @@ pub struct AuditLog { pub user_id: Uuid, pub organization_id: Option, pub action: String, - pub entity_type: String, + pub entity_type: String, pub entity_id: Uuid, pub changes: serde_json::Value, pub created_at: DateTime, @@ -84,7 +84,7 @@ pub struct AuditLogResponse { pub user_id: Uuid, pub user_full_name: Option, pub action: String, - pub entity_type: String, + pub entity_type: String, pub entity_id: Uuid, pub changes: serde_json::Value, pub created_at: DateTime, @@ -133,6 +133,10 @@ pub struct UserResponse { pub struct Organization { pub id: Uuid, pub name: String, + pub logo_url: Option, + pub primary_color: Option, + pub secondary_color: Option, + pub certificate_template: Option, pub created_at: DateTime, pub updated_at: DateTime, }